Compare commits

...

11 commits

Author SHA1 Message Date
22e5890a7c local: integrate yoda libcosmic and fix thumbnail hang
Some checks are pending
Validate .desktop files / validate (push) Waiting to run
2026-05-18 12:22:47 +02:00
Jeremy Soller
d1f63c570c Epoch 1.0.13 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-05-12 12:18:30 -06:00
Jeremy Soller
dbf4b87661
Merge pull request #264 from weblate/weblate-pop-os-cosmic-player
i18n: translation update from Hosted Weblate
2026-05-12 09:42:18 -06:00
Hosted Weblate
0a0f44773f
i18n: translation updates from weblate
Co-authored-by: Adolfo Jayme Barrientos <fitojb@ubuntu.com>
Co-authored-by: Aindriú Mac Giolla Eoin <aindriu80@gmail.com>
Co-authored-by: Baurzhan Muftakhidinov <baurthefirst@gmail.com>
Co-authored-by: BoneNI <bounkirdni@gmail.com>
Co-authored-by: Dan <jonweblin2205@protonmail.com>
Co-authored-by: David Carvalho <david.snt.carvalho@gmail.com>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Fedorov Alexei <aleksejfedorov963@gmail.com>
Co-authored-by: Feike Donia <feikedonia@proton.me>
Co-authored-by: Filippos Papadopoulos <filippospap.developer@gmail.com>
Co-authored-by: Geeson Wan <wang14240@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jim Spentzos <jimspentzos2000@gmail.com>
Co-authored-by: Jiri Grönroos <jiri.gronroos@iki.fi>
Co-authored-by: Julien Brouillard <julienbrouillard1@gmail.com>
Co-authored-by: Konstantinos <kostas.lampropoulos94@gmail.com>
Co-authored-by: Tadas Misiūnas <tadujo@gmail.com>
Co-authored-by: Walter William Beckerleg Bruckman <spayk.99@protonmail.com>
Co-authored-by: lorduskordus <lorduskordus@gmail.com>
Co-authored-by: mcptminei <mcptminei@gmail.com>
Co-authored-by: Димко <Dymkovych@proton.me>
Co-authored-by: Марко М. Костић <marko.m.kostic@gmail.com>
Co-authored-by: 麋麓 BigELK176 <BigELK176@gmail.com>
Co-authored-by: 김유빈 <k.sein1016@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/ca/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/cs/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/de/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/el/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/fi/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/fr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/ga/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/kk/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/ko/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/lt/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/ru/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/sr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/uk/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/zh_Hant/
Translation: Pop OS/COSMIC Player
2026-05-12 11:11:38 +00:00
Jeremy Soller
fab830c994 Translate desktop and metainfo with xdgen 2026-05-07 17:23:41 -06:00
Rodrigo Rivero
f3d6469bed
feat: add skip forward and backward playback controls 2026-05-08 00:53:08 +02:00
Jeremy Soller
468b5f1459 Epoch 1.0.12 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-05-05 12:18:59 -06:00
Vukašin Vojinović
2aa2c841fa chore: update dependencies 2026-04-30 01:33:39 +02:00
Jeremy Soller
a8d9416754 Epoch 1.0.11 version update
Generated by cosmic-epoch scripts/version-update.sh
2026-04-15 13:03:33 -06:00
Jeremy Soller
55357a896a
Merge pull request #248 from weblate/weblate-pop-os-cosmic-player
i18n: translation update from Hosted Weblate
2026-04-14 09:52:19 -06:00
Hosted Weblate
b44c8d2745
i18n: translation updates from weblate
Co-authored-by: Amadɣas <massiin@proton.me>
Co-authored-by: Arve Eriksson <031299870@telia.com>
Co-authored-by: Asier Saratsua Garmendia <asier.sarasua@gmail.com>
Co-authored-by: Baurzhan Muftakhidinov <baurthefirst@gmail.com>
Co-authored-by: Blubberland <github@alias.kardansch.de>
Co-authored-by: ButterflyOfFire <boffire@users.noreply.hosted.weblate.org>
Co-authored-by: David Carvalho <david.snt.carvalho@gmail.com>
Co-authored-by: Dmitry Yarygin <dmitryy7@gmail.com>
Co-authored-by: Ettore Atalan <atalanttore@googlemail.com>
Co-authored-by: Fedorov Alexei <aleksejfedorov963@gmail.com>
Co-authored-by: Geeson Wan <wang14240@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Jiri Grönroos <jiri.gronroos@iki.fi>
Co-authored-by: Julien Brouillard <julienbrouillard1@gmail.com>
Co-authored-by: Urocissa Caerulea.Tw <urocissa.tw@proton.me>
Co-authored-by: VandaL <vandalhj@gmail.com>
Co-authored-by: Vilius Paliokas <viliuspaliokas@gmail.com>
Co-authored-by: Zahid Rizky Fakhri <zahidrizkyfakhri@gmail.com>
Co-authored-by: defaultUser822 <defaultuser822@users.noreply.hosted.weblate.org>
Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>
Co-authored-by: lorduskordus <lorduskordus@gmail.com>
Co-authored-by: therealmate <hellogaming91@gmail.com>
Co-authored-by: кубик круглый <megarainbow29@gmail.com>
Co-authored-by: 麋麓 BigELK176 <BigELK176@gmail.com>
Co-authored-by: 김유빈 <k.sein1016@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/ar/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/cs/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/de/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/fi/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/fr/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/hu/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/id/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/kab/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/kk/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/ko/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/lt/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/pl/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/ru/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/sv/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/pop-os/cosmic-player/zh_Hant/
Translation: Pop OS/COSMIC Player
2026-04-13 21:09:52 +02:00
64 changed files with 3934 additions and 1465 deletions

4
.gitignore vendored
View file

@ -5,4 +5,8 @@
/debian/files
/target/
/vendor/
!/vendor/
/vendor/*
!/vendor/iced_video_player/
!/vendor/iced_video_player/**
/vendor.tar

15
.zed/settings.json Normal file
View file

@ -0,0 +1,15 @@
{
"format_on_save": "on",
"lsp": {
"rust-analyzer": {
"initialization_options": {
"check": {
"command": "clippy",
},
"rustfmt": {
"extraArgs": ["+nightly"],
},
},
},
},
}

2057
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,56 +1,59 @@
[package]
name = "cosmic-player"
version = "1.0.9"
version = "1.0.13"
edition = "2024"
rust-version = "1.93"
[build-dependencies]
vergen = { version = "8", features = ["git", "gitcl"] }
xdgen = "0.1"
[dependencies]
ashpd = { version = "0.12", optional = true }
gstreamer-tag = "0.23"
image = "0.24.9"
gstreamer-tag = "0.25"
image = "0.25"
serde = { version = "1", features = ["serde_derive"] }
tempfile = "3"
tokio = { version = "1", features = ["sync"] }
url = { version = "2", features = ["serde"] }
# CLI arguments
clap_lex = "0.7"
clap_lex = "1.1"
# Internationalization
icu_collator = "1.5"
icu_provider = { version = "1.5", features = ["sync"] }
i18n-embed = { version = "0.14", features = [
icu_collator = "2.2"
icu_locale = "2.2"
i18n-embed = { version = "0.16", features = [
"fluent-system",
"desktop-requester",
] }
i18n-embed-fl = "0.7"
i18n-embed-fl = "0.10"
rust-embed = "8"
# Logging
env_logger = "0.10"
env_logger = "0.11"
log = "0.4"
[dependencies.iced_video_player]
git = "https://github.com/wash2/iced_video_player.git"
branch = "iced-rebase"
path = "vendor/iced_video_player"
default-features = false
[dependencies.libcosmic]
git = "https://github.com/pop-os/libcosmic.git"
[dependencies.cosmic]
package = "libcosmic-yoda"
path = "../libcosmic"
default-features = false
features = ["advanced-shaping", "tokio", "winit", "multi-window"]
[dependencies.mpris-server]
version = "0.8.1"
version = "0.10"
features = ["tokio"]
optional = true
[target.'cfg(unix)'.dependencies]
fork = "0.2"
fork = "0.7"
[features]
default = ["mpris-server", "xdg-portal", "wgpu", "wayland"]
xdg-portal = ["ashpd", "libcosmic/xdg-portal"]
wgpu = ["iced_video_player/wgpu", "libcosmic/wgpu"]
wayland = ["libcosmic/wayland"]
xdg-portal = ["ashpd", "cosmic/xdg-portal"]
wgpu = ["iced_video_player/wgpu", "cosmic/wgpu"]
wayland = ["cosmic/wayland"]
[profile.release-with-debug]
inherits = "release"
@ -59,10 +62,6 @@ debug = true
# [patch.'https://github.com/wash2/iced_video_player']
# iced_video_player = { path = "../iced_video_player" }
# [patch.'https://github.com/pop-os/libcosmic']
# libcosmic = { path = "../libcosmic" }
# cosmic-config = { path = "../libcosmic/cosmic-config" }
# cosmic-theme = { path = "../libcosmic/cosmic-theme" }
# libcosmic = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" }
# cosmic-config = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" }
# cosmic-theme = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" }

View file

@ -1,2 +1,34 @@
# cosmic-player
WIP COSMIC media player
## Yoda local fork notes
This fork is used for the local COSMIC desktop build.
Local integration changes:
- `cosmic-player` depends on the local `libcosmic-yoda` crate from `../libcosmic`
instead of upstream `pop-os/libcosmic`.
- `iced_video_player` is vendored in `vendor/iced_video_player` so it uses the
same local `libcosmic-yoda` and `iced_wgpu` stack. This avoids mixing upstream
and local Iced/libcosmic types.
- The app now runs `App::close()` before application exit and before the Quit
menu action exits the runtime. This lets the GStreamer pipeline be dropped
normally instead of short-circuiting through `process::exit(0)`.
- Thumbnail generation has a bounded wait for video frames. Some files/codecs can
fail to produce a frame; the thumbnailer now returns an error instead of
spinning forever as a background `cosmic-player --thumbnail ...` process.
Validation commands:
```bash
cargo +1.93.0 check
cargo +1.93.0 tree -i libcosmic-yoda --depth 2
cargo +1.93.0 tree -i libcosmic --depth 2 || true
```
Local redeploy:
```bash
COSMIC_PLAYER_REPO=/home/lionel/Projets/COSMIC/cosmic-player ./redeploy.sh
```

View file

@ -1,5 +1,27 @@
use vergen::EmitBuilder;
use std::{env, fs, path::PathBuf};
use xdgen::{App, Context, FluentString};
fn main() {
EmitBuilder::builder().git_sha(true).emit().unwrap();
let id = "com.system76.CosmicPlayer";
let ctx = Context::new("i18n", env::var("CARGO_PKG_NAME").unwrap()).unwrap();
let app = App::new(FluentString("xdg-name"))
.comment(FluentString("xdg-comment"))
.keywords(FluentString("xdg-keywords"));
let output = PathBuf::from("target/xdgen");
fs::create_dir_all(&output).unwrap();
fs::write(
output.join(format!("{}.desktop", id)),
app.expand_desktop(format!("res/{}.desktop", id), &ctx)
.unwrap(),
)
.unwrap();
fs::write(
output.join(format!("{}.metainfo.xml", id)),
app.expand_metainfo(format!("res/{}.metainfo.xml", id), &ctx)
.unwrap(),
)
.unwrap();
}

18
debian/changelog vendored
View file

@ -1,3 +1,21 @@
cosmic-player (1.0.13) noble; urgency=medium
* Epoch 1.0.13 version update
-- Jeremy Soller <jeremy@system76.com> Tue, 12 May 2026 09:39:28 -0600
cosmic-player (1.0.12) noble; urgency=medium
* Epoch 1.0.12 version update
-- Jeremy Soller <jeremy@system76.com> Tue, 05 May 2026 10:24:29 -0600
cosmic-player (1.0.11) noble; urgency=medium
* Epoch 1.0.11 version update
-- Jeremy Soller <jeremy@system76.com> Tue, 14 Apr 2026 11:09:58 -0600
cosmic-player (1.0.9) noble; urgency=medium
* Epoch 1.0.9 version update

View file

@ -1,9 +1,9 @@
album = ألبوم: { $album }
audio = الصوت
no-video-or-audio-file-open = لا يوجد ملف فيديو أو صوت مفتوح
open-file = افتح ملفًا
open-folder = افتح مجلد
subtitles = الترجمة
open-file = افتح ملف
open-folder = افتح مجلَّد
subtitles = الترجمة المرئية
unknown-author = مؤلف مجهول
untitled = بلا عنوان
@ -31,11 +31,24 @@ file = ملف
open-media = افتح الوسائط...
open-recent-media = افتح الوسائط الحديثة
close-file = أغلِق الملف
open-media-folder = فتح مجلد الوسائط...
open-recent-media-folder = فتح مجلد الوسائط الحديثة
open-media-folder = افتح مجلَّد الوسائط...
open-recent-media-folder = افتح مجلِّد الوسائط الحديثة
close-media-folder = أغلِق مجلد الوسائط
quit = أنهِ
off = معطّل
clear-recent = امحُ القائمة الحديثة
repeat-disabled = التكرار مُعطَّل
repeat-track = كرِّر المقطع
playback = التشغيل
next-frame = الإطار التالي
previous-frame = الإطار السابق
ab-repeat = تكرار A-B
ab-repeat-set-a = تكرار A-B (A)
ab-repeat-set-b = تكرار A-B (B)
ab-repeat-clear = امحُ تكرار A-B
# XDG Metadata
xdg-name = مشغّل وسائط COSMIC
xdg-comment = مشغّل وسائط لسطح مكتب COSMIC
xdg-keywords = فيلم;مقطع;فيديو;صوت

View file

@ -0,0 +1,2 @@
quit = Surt
settings = Configuració

View file

@ -22,5 +22,18 @@ close-media-folder = Zavřít složku s médii
quit = Ukončit
off = Vypnuto
clear-recent = Vymazat seznam nedávných
repeat-track = Opakovat skladbu
repeat-disabled = Opakování zakázáno
repeat-track = Opakovat stopu
repeat-disabled = Opakování vypnuto
playback = Přehrávání
next-frame = Další snímek
previous-frame = Předchozí snímek
ab-repeat = Opakování A-B
ab-repeat-set-a = Opakování A-B (A)
ab-repeat-set-b = Opakování A-B (B)
ab-repeat-clear = Vymazat opakování A-B
# XDG Metadata
xdg-name = Přehrávač médií COSMIC
xdg-comment = Přehrávač médií pro prostředí COSMIC
xdg-keywords = Audio;Film;Hudba;Zvuk;Video;

View file

@ -29,7 +29,7 @@ light = Hell
file = Datei
open-media = Medium öffnen...
open-recent-media = Zuletzt verwendete Medien öffnen
open-recent-media = Zuletzt verwendetes Medium öffnen
close-file = Datei schließen
open-media-folder = Medienordner öffnen...
open-recent-media-folder = Ordner der zuletzt verwendeten Medien öffnen
@ -38,3 +38,14 @@ quit = Beenden
off = Aus
repeat-disabled = Wiederholung deaktiviert
repeat-track = Titel wiederholen
clear-recent = Liste zuletzt genutzter Einträge leeren
playback = Wiedergabe
next-frame = Nächstes Einzelbild
previous-frame = Vorheriges Einzelbild
ab-repeat = A-B-Wiederholung
ab-repeat-set-a = A-B-Wiederholung (A)
ab-repeat-set-b = A-B-Wiederholung (B)
ab-repeat-clear = A-B-Wiederholung löschen
xdg-keywords = Audio;Film;Musik;Ton;Video;
xdg-name = COSMIC Mediaplayer
xdg-comment = Mediaplayer für den COSMIC-Desktop

View file

@ -1,2 +1,36 @@
dark = Σκοτεινό
light = Φωτεινό
dark = Σκουρόχρωμο
light = Ανοιχτόχρωμο
file = Αρχείο
open-file = Άνοιγμα αρχείου
open-folder = Άνοιγμα φακέλου
settings = Ρυθμίσεις
close-file = Κλείσιμο αρχείου
subtitles = Υπότιτλοι
untitled = Χωρίς τίτλο
theme = Θέμα
repeat-track = Επανάληψη κομματιού
appearance = Εμφάνιση
quit = Έξοδος
album = Άλμπουμ: { $album }
playback = Αναπαραγωγή
next-frame = Επόμενο καρέ
previous-frame = Προηγούμενο καρέ
ab-repeat = Επανάληψη Α-Β
ab-repeat-set-a = Επανάληψη Α-Β (Α)
ab-repeat-set-b = Επανάληψη A-B (B)
open-media = Άνοιγμα πολυμέσων...
open-media-folder = Άνοιγμα φακέλου πολυμέσων...
unknown-author = Άγνωστος δημιουργός
match-desktop = Συμφωνία με την επιφάνεια εργασίας
close-media-folder = Κλείσιμο φακέλου πολυμέσων
repeat-disabled = Επανάληψη απενεργοποιημένη
audio = Κομμάτι ήχου
off = Ανενεργό
open-recent-media = Άνοιγμα πρόσφατων πολυμέσων
clear-recent = Απαλοιφή λίστας πρόσφατων
no-video-or-audio-file-open = Κανένα ανοικτό αρχείο βίντεο ή ήχου
open-recent-media-folder = Άνοιγμα φακέλου πρόσφατων πολυμέσων
ab-repeat-clear = Απαλοιφή επανάληψης Α-Β
xdg-name = Αναπαραγωγή πολυμέσων COSMIC
xdg-keywords = Ήχος;Ταινία;Μουσική;Τραγούδι;Βίντεο;Πολυμέσα;Audio;Film;Movie;Music;Sound;Video;
xdg-comment = Πρόγραμμα αναπαραγωγής πολυμέσων για το περιβάλλον επιφάνειας εργασίας COSMIC

View file

@ -45,3 +45,9 @@ ab-repeat = A-B Repeat
ab-repeat-set-a = A-B Repeat (A)
ab-repeat-set-b = A-B Repeat (B)
ab-repeat-clear = Clear A-B Repeat
# XDG Metadata
xdg-name = COSMIC Media Player
xdg-comment = Media player for the COSMIC desktop
xdg-keywords = Audio;Film;Movie;Music;Sound;Video;

View file

@ -36,3 +36,9 @@ open-recent-media-folder = Abrir carpetas multimedia recientes
close-media-folder = Cerrar carpeta multimedia
quit = Salir
off = Apagado
# XDG Metadata
xdg-name = Reproductor de medios COSMIC
xdg-comment = Reproductor de medios para el escritorio COSMIC
xdg-keywords = Audio;Película;Música;Vídeo;Sonido;Reproductor;

View file

View file

@ -11,7 +11,7 @@ unknown-author = Tuntematon tekijä
untitled = Nimetön
appearance = Ulkoasu
theme = Teema
match-desktop = Sovita työpöydän kanssa
match-desktop = Sovita työpöytään
dark = Tumma
light = Vaalea
file = Tiedosto
@ -24,3 +24,13 @@ open-media = Avaa media...
open-recent-media = Avaa viimeaikainen media
repeat-disabled = Kertaus pois käytöstä
repeat-track = Kertaa kappale
playback = Toisto
next-frame = Seuraava kehys
previous-frame = Edellinen kehys
ab-repeat = A-B-kertaus
ab-repeat-set-a = A-B-kertaus (A)
ab-repeat-set-b = A-B-kertaus (B)
ab-repeat-clear = Tyhjennä A-B-kertaus
xdg-name = COSMICin mediasoitin
xdg-comment = Mediasoitin COSMIC-työpöydälle
xdg-keywords = Audio;Film;Movie;Music;Sound;Video;ääni;filmi;leffa;elokuva;musiikki;

View file

@ -39,3 +39,13 @@ off = Désactivé
clear-recent = Effacer la liste récente
repeat-disabled = Répétition désactivée
repeat-track = Répéter la piste
playback = Lecture
next-frame = Prochaine image
previous-frame = Image précédente
ab-repeat = Répétition A-B
ab-repeat-set-a = Répétition A-B (A)
ab-repeat-set-b = Répétition A-B (B)
ab-repeat-clear = Effacer la répétition A-B
xdg-name = Lecteur Multimédia COSMIC
xdg-comment = Lecteur Multimédia pour le bureau COSMIC
xdg-keywords = Audio;Film;Musique;Son;Vidéo;

View file

@ -39,3 +39,10 @@ off = As
clear-recent = Glan an liosta le déanaí
repeat-disabled = Athsheinm múchta
repeat-track = Athsheinm an riain
playback = Athsheinm
next-frame = An chéad fráma eile
previous-frame = An fráma roimhe seo
ab-repeat = Athsheinm A-B
ab-repeat-set-a = Athsheinm A-B (A)
ab-repeat-set-b = Athsheinm A-B (B)
ab-repeat-clear = Glan Athsheinm A-B

View file

@ -39,3 +39,16 @@ off = Ki
clear-recent = Legutóbbiak listájának törlése
repeat-disabled = Ismétlés letiltva
repeat-track = Szám ismétlése
playback = Lejátszás
next-frame = Következő képkocka
previous-frame = Előző képkocka
ab-repeat = A–B ismétlés
ab-repeat-set-a = A–B ismétlés (A)
ab-repeat-set-b = A–B ismétlés (B)
ab-repeat-clear = A–B ismétlés törlése
# XDG Metadata
xdg-name = COSMIC Médialejátszó
xdg-comment = Médialejátszó a COSMIC asztali környezethez
xdg-keywords = hang;film;zene;videó;média;lejátszó;

View file

@ -24,3 +24,10 @@ close-media-folder = Tutup map media
clear-recent = Hapus daftar terbaru
repeat-track = Ulangi trek
repeat-disabled = Pengulangan dinonaktifkan
ab-repeat-clear = Bersihkan Pengulangan A-B
ab-repeat-set-b = Pengulangan A-B (B)
ab-repeat-set-a = Pengulangan A-B (A)
ab-repeat = Pengulangan A-B
playback = Pemutaran
next-frame = Bagian Selanjutnya
previous-frame = Bagian Sebelumnya

View file

@ -36,3 +36,9 @@ open-recent-media-folder = Apri cartella multimediale recente
close-media-folder = Chiudi cartella multimediale
quit = Esci
off = Off
# XDG Metadata
xdg-name = COSMIC Media Player
xdg-comment = Lettore multimediale di COSMIC
xdg-keywords = Audio;Video;Musica;Film;Suoni;Riproduzione;

View file

@ -0,0 +1,33 @@
open-file = Ldi afaylu
open-folder = Ldi akaram
repeat-disabled = Allus yensa
repeat-track = Ales tazribt
quit = Tuffɣa
settings = Iɣewwaṛen
appearance = Timeẓri
theme = Asentel
dark = Aɣmayan
light = Aceɛlal
match-desktop = Amṣada d tnarit
file = Afaylu
close-file = Mdel afaylu
album = Album: { $album }
audio = Ameslaw
no-video-or-audio-file-open = Ulac afaylu n uvidyu neɣ n umeslaw yeldin
off = Sens
subtitles = Iduzwilen
unknown-author = Ameskar arussin
untitled = War azwel
open-media = Ldi amidya...
open-recent-media = Ldi amidya n melmi kan
clear-recent = Sfeḍ tabdart n melmi kan
open-media-folder = Ldi akaram n umidya...
open-recent-media-folder = Ldi akaram umidya n melmi kan
close-media-folder = Mdel akaram n umidya
playback = Taɣuri
next-frame = Akatar uḍfir
previous-frame = Akatar yezrin
ab-repeat = Allus A-B
ab-repeat-set-a = Allus A-B (A)
ab-repeat-set-b = Allus A-B (B)
ab-repeat-clear = Sfeḍ allus A-B

View file

@ -24,3 +24,13 @@ open-recent-media-folder = Жуырдағы медиа бумасын ашу
close-media-folder = Медиа бумасын жабу
repeat-disabled = Қайталау сөндірілген
repeat-track = Тректі қайталау
playback = Ойнату
next-frame = Келесі фрейм
previous-frame = Алдыңғы фрейм
ab-repeat = Қайталау А-Ә
ab-repeat-set-a = Қайталау А-Ә (А бапт.)
ab-repeat-set-b = Қайталау А-Ә (В бапт.)
ab-repeat-clear = Қайталау А-Ә (Тазарту)
xdg-name = COSMIC медиаплеері
xdg-comment = COSMIC жұмыс үстелі үшін медиаплеері
xdg-keywords = Audio;Film;Movie;Music;Sound;Video;Аудио;Фильм;Кино;Музыка;Дыбыс;Видео;

View file

@ -22,3 +22,6 @@ no-video-or-audio-file-open = 비디오 혹은 오디오가 열려있지 않음
off = 끄기
album = 앨범: { $album }
close-file = 파일 닫기
repeat-disabled = 반복 비활성화
previous-frame = 이전 프레임
next-frame = 다음 프레임

View file

View file

@ -1,5 +1,5 @@
quit = Išeiti
settings = Nustatymai
settings = Nuostatos
close-file = Uždaryti failą
match-desktop = Pagal darbalaukio temą
appearance = Išvaizda
@ -24,3 +24,10 @@ dark = Tamsus
light = Šviesus
repeat-track = Kartoti takelį
repeat-disabled = Kartojimas išjungtas
playback = Atkūrimas
next-frame = Sekantis Kadras
previous-frame = Ankstenis Kadras
ab-repeat = A-B Kartojimas
ab-repeat-set-a = A-B Kartojimas (A)
ab-repeat-set-b = A-B Kartojimas (B)
ab-repeat-clear = Panaikinti A-B Kartojimą

View file

@ -39,3 +39,16 @@ off = Wyłączone
clear-recent = Wyczyść listę poprzednich
repeat-disabled = Powtarzanie wyłączone
repeat-track = Powtórz utwór
playback = Odtwarzanie
next-frame = Następna Klatka
ab-repeat = A-B Powtarzanie
ab-repeat-set-a = A-B Powtarzanie (A)
ab-repeat-set-b = A-B Powtarzanie (B)
ab-repeat-clear = Wyczyść Powtarzanie A-B
previous-frame = Poprzednia Klatka
# XDG Metadata
xdg-name = Odtwarzacz Multimediów COSMIC
xdg-comment = Odtwarzacz Multimediów dla środowiska graficznego COSMIC
xdg-keywords = Audio;Dźwięki;Filmy;Muzyka;Vídeo;Media;Odtwarzacz;Multimedia;

View file

@ -39,3 +39,16 @@ off = Desligado
clear-recent = Limpar lista de recentes
repeat-track = Repetir faixa
repeat-disabled = Repetição desabilitada
next-frame = Próximo quadro
previous-frame = Quadro anterior
playback = Reprodução
ab-repeat = Repetir A-B
ab-repeat-set-a = Repetir A-B (A)
ab-repeat-set-b = Repetir A-B (B)
ab-repeat-clear = Limpar repetição A-B
# XDG Metadata
xdg-name = Reprodutor de mídia
xdg-comment = Reprodutor de mídia do ambiente COSMIC
xdg-keywords = Áudio;Filme;Música;Som;Vídeo;Media;Player;

View file

@ -35,3 +35,9 @@ open-media-folder = Abrir pasta de multimédias...
open-recent-media-folder = Abrir pasta recente de multimédias
close-media-folder = Fechar pasta de multimédia
quit = Sair
# XDG Metadata
xdg-name = Reprodutor de Mídia
xdg-comment = Reprodutor de mídia do desktop COSMIC
xdg-keywords = Audio;Filme;Música;Som;Vídeo;Media;Player;

View file

@ -15,7 +15,7 @@ light = Светлая
open-file = Открыть файл
album = Альбом: { $album }
audio = Аудио
no-video-or-audio-file-open = Нет открытых аудио- или видеофайлов
no-video-or-audio-file-open = Нет открытых видео или аудио файлов
open-folder = Открыть папку
subtitles = Субтитры
unknown-author = Неизвестный автор
@ -24,11 +24,24 @@ file = Файл
open-media = Открыть медиафайл…
open-recent-media = Открыть недавний медиафайл
close-file = Закрыть файл
open-media-folder = Открыть папку медиафайлов…
open-recent-media-folder = Недавние папки медиафайлов
open-media-folder = Открыть папку медиафайлов...
open-recent-media-folder = Открыть папку недавних медиафайлов
close-media-folder = Закрыть папку медиафайлов
quit = Выйти
off = Выкл.
clear-recent = Очистить список недавних
repeat-disabled = Повтор отключён
repeat-track = Повторять трек
playback = Воспроизведение
next-frame = Следующий кадр
previous-frame = Предыдущий кадр
ab-repeat = Повтор A–B
ab-repeat-set-a = Повтор A–B (уст. A)
ab-repeat-set-b = Повтор A–B (уст. B)
ab-repeat-clear = Повтор A–B (очистить)
# XDG Metadata
xdg-name = Медиаплеер
xdg-keywords = Аудио;Фильм;Музыка;Видео;Медиа;Звук;Мультимедиа;Медиаплеер;
xdg-comment = Медиаплеер для рабочей среды COSMIC

View file

@ -30,3 +30,9 @@ open-media-folder = Otvoriť priečinok s médiami...
open-recent-media-folder = Otvoriť posledný priečinok s médiami
close-media-folder = Zatvoriť priečinok s médiami
quit = Ukončiť
# XDG Metadata
xdg-name = Prehrávač Médií COSMIC
xdg-comment = Prehrávač médií pre pracovné prostredie COSMIC
xdg-keywords = audio;film;hudba;video;prehrávač;média;zvuk;

View file

@ -0,0 +1,36 @@
open-file = Отвори датотеку
quit = Изађи
settings = Подешавања
appearance = Изглед
dark = Тамна
light = Светла
open-folder = Отвори фасциклу
theme = Тема
match-desktop = Прати радну површину
file = Датотека
close-file = Затвори датотеку
album = Албум: { $album }
audio = Звук
no-video-or-audio-file-open = Нема отворене датотеке видеа или звука
off = Искључено
subtitles = Поднаслови
unknown-author = Непознати аутор
untitled = Без наслова
open-media = Отвори медиј...
open-recent-media = Отвори недавне медије
clear-recent = Очисти списак недавних
open-media-folder = Отвори медијску фасциклу...
open-recent-media-folder = Отвори недавну медијску фасциклу
close-media-folder = Затвори медијску фасциклу
repeat-disabled = Понављање онемогућено
repeat-track = Понови песму
playback = Пуштање
next-frame = Следећи кадар
previous-frame = Претходни кадар
ab-repeat = Понављање А-Б
ab-repeat-set-a = А-Б понављање (А)
ab-repeat-set-b = А-Б понављање (Б)
ab-repeat-clear = Очисти А-Б понављање
xdg-name = Космик пуштач медија
xdg-comment = Пуштач медија за Космик радну површ
xdg-keywords = Audio;Film;Movie;Music;Sound;Video; Аудио;Звук;Филм;Музика;Видео;zvuk;film;muzika;

View file

@ -39,3 +39,16 @@ off = Av
clear-recent = Töm senaste-listan
repeat-disabled = Upprepning avaktiverad
repeat-track = Upprepa spår
playback = Uppspelning
next-frame = Nästa bildruta
previous-frame = Föregående bildruta
ab-repeat = A-B-upprepning
ab-repeat-set-a = A-B-upprepning (A)
ab-repeat-set-b = A-B-upprepning (B)
ab-repeat-clear = Rensa A-B-upprepning
# XDG Metadata
xdg-name = COSMIC Mediaspelare
xdg-comment = Mediaspelare för skrivbordsmiljön COSMIC
xdg-keywords = Ljud;Film;Musik;Video;Media;

View file

@ -13,14 +13,24 @@ match-desktop = Системна
dark = Темна
light = Світла
file = Файл
open-media = Відкрити медіа...
open-recent-media = Відкрити останні медіа
open-media = Відкрити...
open-recent-media = Відкрити попередні
close-file = Закрити файл
open-media-folder = Відкрити теку з медіа...
open-recent-media-folder = Відкрити нещодавню теку з медіа
close-media-folder = Закрити теку з медіа
open-media-folder = Відкрити теку...
open-recent-media-folder = Відкрити попередню теку
close-media-folder = Закрити теку
quit = Вийти
off = Вимк.
clear-recent = Очистити список
repeat-disabled = Не повторювати
repeat-track = Повторювати
previous-frame = Попередній кадр
next-frame = Наступний кадр
xdg-name = Програвач COSMIC
xdg-comment = Програвач для стільниці COSMIC
xdg-keywords = Аудіо;Стрічка;Фільм;Кіно;Музика;Звук;Відео;
playback = Програти
ab-repeat = Повторення A-B
ab-repeat-set-a = Повторення A-B (A)
ab-repeat-set-b = Повторення A-B (B)
ab-repeat-clear = Очистити повтор A-B

View file

@ -28,10 +28,10 @@ light = 亮色模式
## File
file = 文件
open-media = 打开媒体文件...
open-media = 打开媒体文件…
open-recent-media = 打开最近媒体文件
close-file = 关闭文件
open-media-folder = 打开媒体文件夹...
open-media-folder = 打开媒体文件夹…
open-recent-media-folder = 打开最近媒体文件夹
close-media-folder = 关闭媒体文件夹
quit = 退出
@ -39,3 +39,16 @@ off = 已关闭
clear-recent = 清空近期列表
repeat-disabled = 重复已禁用
repeat-track = 重复轨道
playback = 回放
next-frame = 下一帧
previous-frame = 上一帧
ab-repeat = A-B 重复
ab-repeat-set-a = A-B 重复(A)
ab-repeat-set-b = A-B 重复(B)
ab-repeat-clear = 清除 A-B 重复
# XDG Metadata
xdg-name = COSMIC 媒体播放器
xdg-comment = COSMIC 桌面的媒体播放器
xdg-keywords = 音频;影片;电影;音乐;声音;视频;

View file

@ -1,32 +1,51 @@
album = 專輯: {$album}
album = 專輯:{ $album }
audio = 音訊
no-video-or-audio-file-open = 未開啟視訊或音訊檔案
no-video-or-audio-file-open = 無影片或音訊檔案開啟
open-file = 開啟檔案
open-folder = 打開資料夾
open-folder = 開啟資料夾
subtitles = 字幕
unknown-author = 未知作者
unknown-author = 不明作者
untitled = 無標題
# Context Pages
## Settings
settings = 設定
### Appearance
appearance = 外觀
theme = 主題
match-desktop = 與桌面保持一致
dark = 深色主題
light = 淺色主題
match-desktop = 符合桌面
dark = 深色
light = 淺色
# Menu
## File
file = 檔案
open-media = 開啟媒體檔案...
open-recent-media = 打開最近的媒體檔案
open-media = 開啟媒體...
open-recent-media = 開啟最近使用的媒體
close-file = 關閉檔案
open-media-folder = 開啟媒體資料夾...
open-recent-media-folder = 開啟最近的媒體資料夾
open-recent-media-folder = 開啟最近使用的媒體資料夾
close-media-folder = 關閉媒體資料夾
quit = 退出
quit = 退出
off = 關閉
clear-recent = 清除最近使用清單
repeat-disabled = 重複已經停用
repeat-track = 重複曲目
ab-repeat = A-B 重複
ab-repeat-set-a = A-B 重複(A)
ab-repeat-set-b = A-B 重複(B)
ab-repeat-clear = 清除 A-B 重複
playback = 回放
next-frame = 下一幀
previous-frame = 上一幀
xdg-name = COSMIC 媒體播放器
xdg-comment = 媒體播放器 用於 COSMIC 桌面
xdg-keywords = 音訊;影片;電影;音樂;聲音;影像;

View file

@ -13,11 +13,11 @@ bin-src := cargo-target-dir / 'release' / name
bin-dst := base-dir / 'bin' / name
desktop := APPID + '.desktop'
desktop-src := 'res' / desktop
desktop-src := 'target/xdgen' / desktop
desktop-dst := clean(rootdir / prefix) / 'share' / 'applications' / desktop
metainfo := APPID + '.metainfo.xml'
metainfo-src := 'res' / metainfo
metainfo-src := 'target/xdgen' / metainfo
metainfo-dst := clean(rootdir / prefix) / 'share' / 'metainfo' / metainfo
thumbnailer := APPID + '.thumbnailer'

View file

@ -1,18 +1,6 @@
#TODO: more build-out, desktop actions, translations?
[Desktop Entry]
Name=COSMIC Media Player
Name[ar]=مشغّل وسائط COSMIC
Name[cs]=Přehrávač médií COSMIC
Name[zh_CN]=COSMIC 媒体播放器
Name[hu]=COSMIC Médialejátszó
Name[pl]=Odtwarzacz Multimediów COSMIC
Name[pt_BR]=Reprodutor de Mídia
Name[pt]=Reprodutor de Mídia
Name[ru]=Медиаплеер
Name[sk]=Prehrávač Médií COSMIC
Name[sv]=COSMIC Mediaspelare
Name[es]=Reproductor de medios COSMIC
Name[it]=COSMIC Media Player
Comment=Media player for the COSMIC desktop
Exec=cosmic-player %U
Terminal=false
Type=Application
@ -21,14 +9,3 @@ MimeType=application/mxf;application/ram;application/sdp;application/vnd.apple.m
Icon=com.system76.CosmicPlayer
Categories=COSMIC;AudioVideo;Player;Video;
Keywords=Audio;Film;Movie;Music;Sound;Video;
Keywords[ar]=فيلم;مقطع;فيديو;صوت
Keywords[cs]=audio;film;hudba;video;média;zvuk;
Keywords[hu]=hang;film;zene;videó;média;lejátszó;
Keywords[pl]=Audio;Dźwięki;Filmy;Muzyka;Vídeo;Media;Odtwarzacz;Multimedia;
Keywords[pt_BR]=Audio;Filme;Música;Som;Vídeo;Media;Player;
Keywords[ru]=Аудио;Фильм;Музыка;Видео;Медиа;Звук;Мультимедиа;Медиаплеер;
Keywords[pt]=Audio;Filme;Música;Som;Vídeo;Media;Player;
Keywords[sk]=audio;film;hudba;video;prehrávač;média;zvuk;
Keywords[sv]=Ljud;Film;Musik;Video;Media;
Keywords[es]=Audio;Película;Música;Vídeo;Sonido;Reproductor;
Keywords[it]=Audio;Video;Musica;Film;Suoni;Riproduzione;

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml version="1.0" encoding="UTF-8" ?>
<component type="desktop-application">
<id>com.system76.CosmicPlayer</id>
<metadata_license>CC0-1.0</metadata_license>
@ -9,33 +9,15 @@
<url type="homepage">https://github.com/pop-os/cosmic-player</url>
<url type="bugtracker">https://github.com/pop-os/cosmic-player</url>
<name>COSMIC Media Player</name>
<name xml:lang="ar">مشغّل وسائط COSMIC</name>
<name xml:lang="cs">Přehrávač médií COSMIC</name>
<name xml:lang="hu">COSMIC Médialejátszó</name>
<name xml:lang="pl">Odtwarzacz Multimediów COSMIC</name>
<name xml:lang="pt_BR">Reprodutor de Mídia</name>
<name xml:lang="pt">Reprodutor de Mídia</name>
<name xml:lang="sk">Prehrávač Médií COSMIC</name>
<name xml:lang="es">Reproductor de medios COSMIC</name>
<name xml:lang="it">COSMIC Media Player</name>
<name xml:lang="sv">COSMIC Mediaspelare</name>
<summary>Media player for the COSMIC desktop</summary>
<summary xml:lang="ar">مشغّل وسائط لسطح مكتب COSMIC</summary>
<summary xml:lang="cs">Přehrávač médií pro pracovní prostředí COSMIC</summary>
<summary xml:lang="hu">Médialejátszó a COSMIC asztali környezethez</summary>
<summary xml:lang="pl">Odtwarzacz Multimediów dla środowiska graficznego COSMIC</summary>
<summary xml:lang="pt_BR">Reprodutor de mídia do desktop COSMIC</summary>
<summary xml:lang="pt">Reprodutor de mídia do desktop COSMIC</summary>
<summary xml:lang="sk">Prehrávač médií pre pracovné prostredie COSMIC</summary>
<summary xml:lang="es">Reproductor de medios para el escritorio COSMIC</summary>
<summary xml:lang="it">Lettore multimediale di COSMIC</summary>
<summary xml:lang="sv">Mediaspelare för skrivbordsmiljön COSMIC</summary>
<description>
<p>Media player for the COSMIC desktop</p>
<p xml:lang="ar">مشغّل وسائط لسطح مكتب COSMIC</p>
<p xml:lang="cs">Přehrávač médií pro pracovní prostředí COSMIC</p>
<p xml:lang="hu">Médialejátszó a COSMIC asztali környezethez</p>
<p xml:lang="pl">Odtwarzacz Multimediów dla środowiska graficznego COSMIC</p>
<p
xml:lang="pl"
>Odtwarzacz Multimediów dla środowiska graficznego COSMIC</p>
<p xml:lang="pt_BR">Reprodutor de mídia do desktop COSMIC</p>
<p xml:lang="pt">Reprodutor de mídia do desktop COSMIC</p>
<p xml:lang="sk">Prehrávač médií pre pracovné prostredie COSMIC</p>
@ -43,8 +25,20 @@
<p xml:lang="es">Reproductor de medios para el escritorio COSMIC</p>
<p xml:lang="sv">Mediaspelare för skrivbordsmiljön COSMIC</p>
</description>
<keywords>
<keyword>Audio</keyword>
<keyword>Film</keyword>
<keyword>Movie</keyword>
<keyword>Music</keyword>
<keyword>Sound</keyword>
<keyword>Video</keyword>
</keywords>
<launchable type="desktop-id">com.system76.CosmicPlayer.desktop</launchable>
<icon type="remote" height="256" width="256">https://raw.githubusercontent.com/pop-os/cosmic-player/master/res/icons/hicolor/256x256/apps/com.system76.CosmicPlayer.svg</icon>
<icon
type="remote"
height="256"
width="256"
>https://raw.githubusercontent.com/pop-os/cosmic-player/master/res/icons/hicolor/256x256/apps/com.system76.CosmicPlayer.svg</icon>
<provides>
<id>com.system76.CosmicApplication</id>
<binaries>

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.1693 13.9167C3.11349 13.9167 3.06326 13.8972 3.01862 13.8583C2.97955 13.8194 2.96002 13.7694 2.96002 13.7083V9.56667L1.75459 10.4917C1.70436 10.5306 1.65135 10.5444 1.59554 10.5333C1.54531 10.5222 1.50067 10.4917 1.4616 10.4417L1.04305 9.9C1.00957 9.85 0.995615 9.79722 1.0012 9.74167C1.01236 9.68611 1.04305 9.64167 1.09328 9.60833L3.00187 8.14167C3.03536 8.11944 3.06605 8.10556 3.09396 8.1C3.12744 8.08889 3.16372 8.08333 3.20278 8.08333H4.09011C4.14592 8.08333 4.19335 8.10278 4.23242 8.14167C4.27148 8.18056 4.29102 8.23056 4.29102 8.29167V13.7083C4.29102 13.7694 4.27148 13.8194 4.23242 13.8583C4.19335 13.8972 4.14592 13.9167 4.09011 13.9167H3.1693Z" fill="#232323"/>
<path d="M7.66029 14C7.25848 14 6.91248 13.9444 6.62228 13.8333C6.33767 13.7167 6.10049 13.5583 5.91075 13.3583C5.72658 13.1528 5.58707 12.9167 5.49219 12.65C5.4029 12.3778 5.34989 12.0889 5.33314 11.7833C5.32756 11.6333 5.32198 11.4667 5.3164 11.2833C5.3164 11.0944 5.3164 10.9056 5.3164 10.7167C5.32198 10.5278 5.32756 10.3556 5.33314 10.2C5.34431 9.89444 5.39732 9.60833 5.49219 9.34167C5.59265 9.075 5.73774 8.84167 5.92749 8.64167C6.11723 8.44167 6.35441 8.28611 6.63903 8.175C6.92922 8.05833 7.26965 8 7.66029 8C8.05094 8 8.38857 8.05833 8.67319 8.175C8.95781 8.28611 9.19499 8.44167 9.38473 8.64167C9.57447 8.84167 9.71678 9.075 9.81165 9.34167C9.9121 9.60833 9.9707 9.89444 9.98744 10.2C9.99302 10.3556 9.99581 10.5278 9.99581 10.7167C10.0014 10.9056 10.0014 11.0944 9.99581 11.2833C9.99581 11.4667 9.99302 11.6333 9.98744 11.7833C9.9707 12.0889 9.91489 12.3778 9.82002 12.65C9.72515 12.9167 9.58284 13.1528 9.3931 13.3583C9.20894 13.5583 8.97176 13.7167 8.68156 13.8333C8.39695 13.9444 8.05652 14 7.66029 14ZM7.66029 12.9167C7.98397 12.9167 8.22115 12.8111 8.37183 12.6C8.52251 12.3889 8.60343 12.1028 8.61459 11.7417C8.62017 11.5806 8.62296 11.4139 8.62296 11.2417C8.62854 11.0694 8.62854 10.8972 8.62296 10.725C8.62296 10.5528 8.62017 10.3917 8.61459 10.2417C8.60343 9.89722 8.52251 9.61667 8.37183 9.4C8.22115 9.18333 7.98397 9.07222 7.66029 9.06667C7.33103 9.07222 7.09106 9.18333 6.94038 9.4C6.79529 9.61667 6.71716 9.89722 6.706 10.2417C6.70041 10.3917 6.69483 10.5528 6.68925 10.725C6.68925 10.8972 6.68925 11.0694 6.68925 11.2417C6.69483 11.4139 6.70041 11.5806 6.706 11.7417C6.71716 12.1028 6.79808 12.3889 6.94876 12.6C7.09943 12.8111 7.33661 12.9167 7.66029 12.9167Z" fill="#232323"/>
<path d="M1.5 4L6 7V5.01465C6.16505 5.00595 6.33174 5 6.5 5C10.0899 5 13 7.01472 13 9.5C13 10.4041 12.613 11.244 11.9502 11.9492C11.6152 12.3713 11.6558 12.9781 12.0479 13.3281C12.455 13.6916 13.1076 13.6232 13.4854 13.2002C14.4387 12.1498 15 10.8755 15 9.5C15 5.91015 11.1944 3 6.5 3C6.33214 3 6.16545 3.00438 6 3.01172V1L1.5 4Z" fill="#232323"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.1693 13.9167C7.11349 13.9167 7.06326 13.8972 7.01862 13.8583C6.97955 13.8194 6.96002 13.7694 6.96002 13.7083V9.56667L5.75459 10.4917C5.70436 10.5306 5.65135 10.5444 5.59554 10.5333C5.54531 10.5222 5.50067 10.4917 5.4616 10.4417L5.04305 9.9C5.00957 9.85 4.99562 9.79722 5.0012 9.74167C5.01236 9.68611 5.04305 9.64167 5.09328 9.60833L7.00187 8.14167C7.03536 8.11944 7.06605 8.10556 7.09396 8.1C7.12744 8.08889 7.16372 8.08333 7.20278 8.08333H8.09011C8.14592 8.08333 8.19335 8.10278 8.23242 8.14167C8.27148 8.18056 8.29102 8.23056 8.29102 8.29167V13.7083C8.29102 13.7694 8.27148 13.8194 8.23242 13.8583C8.19335 13.8972 8.14592 13.9167 8.09011 13.9167H7.1693Z" fill="#232323"/>
<path d="M11.6603 14C11.2585 14 10.9125 13.9444 10.6223 13.8333C10.3377 13.7167 10.1005 13.5583 9.91075 13.3583C9.72658 13.1528 9.58707 12.9167 9.49219 12.65C9.4029 12.3778 9.34989 12.0889 9.33314 11.7833C9.32756 11.6333 9.32198 11.4667 9.3164 11.2833C9.3164 11.0944 9.3164 10.9056 9.3164 10.7167C9.32198 10.5278 9.32756 10.3556 9.33314 10.2C9.34431 9.89444 9.39732 9.60833 9.49219 9.34167C9.59265 9.075 9.73774 8.84167 9.92749 8.64167C10.1172 8.44167 10.3544 8.28611 10.639 8.175C10.9292 8.05833 11.2696 8 11.6603 8C12.0509 8 12.3886 8.05833 12.6732 8.175C12.9578 8.28611 13.195 8.44167 13.3847 8.64167C13.5745 8.84167 13.7168 9.075 13.8117 9.34167C13.9121 9.60833 13.9707 9.89444 13.9874 10.2C13.993 10.3556 13.9958 10.5278 13.9958 10.7167C14.0014 10.9056 14.0014 11.0944 13.9958 11.2833C13.9958 11.4667 13.993 11.6333 13.9874 11.7833C13.9707 12.0889 13.9149 12.3778 13.82 12.65C13.7252 12.9167 13.5828 13.1528 13.3931 13.3583C13.2089 13.5583 12.9718 13.7167 12.6816 13.8333C12.3969 13.9444 12.0565 14 11.6603 14ZM11.6603 12.9167C11.984 12.9167 12.2212 12.8111 12.3718 12.6C12.5225 12.3889 12.6034 12.1028 12.6146 11.7417C12.6202 11.5806 12.623 11.4139 12.623 11.2417C12.6285 11.0694 12.6285 10.8972 12.623 10.725C12.623 10.5528 12.6202 10.3917 12.6146 10.2417C12.6034 9.89722 12.5225 9.61667 12.3718 9.4C12.2212 9.18333 11.984 9.07222 11.6603 9.06667C11.331 9.07222 11.0911 9.18333 10.9404 9.4C10.7953 9.61667 10.7172 9.89722 10.706 10.2417C10.7004 10.3917 10.6948 10.5528 10.6893 10.725C10.6893 10.8972 10.6893 11.0694 10.6893 11.2417C10.6948 11.4139 10.7004 11.5806 10.706 11.7417C10.7172 12.1028 10.7981 12.3889 10.9488 12.6C11.0994 12.8111 11.3366 12.9167 11.6603 12.9167Z" fill="#232323"/>
<path d="M14.5 4L10 7V5.01465C9.83495 5.00595 9.66826 5 9.5 5C5.91015 5 3 7.01472 3 9.5C3 10.4041 3.38701 11.244 4.0498 11.9492C4.38477 12.3713 4.34421 12.9781 3.95215 13.3281C3.545 13.6916 2.89241 13.6232 2.51465 13.2002C1.56125 12.1498 1 10.8755 1 9.5C1 5.91015 4.80558 3 9.5 3C9.66786 3 9.83455 3.00438 10 3.01172V1L14.5 4Z" fill="#232323"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -1 +1 @@
reorder_imports = false
imports_granularity = "Module"

View file

@ -1,7 +1,8 @@
// Copyright 2024 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use std::{fs, io, path::PathBuf};
use std::path::PathBuf;
use std::{fs, io};
use clap_lex::RawArgs;
use log::warn;

View file

@ -1,11 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::{
cosmic_config::{self, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry},
theme,
};
use cosmic::cosmic_config::cosmic_config_derive::CosmicConfigEntry;
use cosmic::cosmic_config::{self, CosmicConfigEntry};
use cosmic::theme;
use serde::{Deserialize, Serialize};
use std::{collections::VecDeque, path::PathBuf};
use std::collections::VecDeque;
use std::path::PathBuf;
pub const CONFIG_VERSION: u64 = 1;

View file

@ -1,4 +1,5 @@
use cosmic::{iced::keyboard::Key, iced::core::keyboard::key::Named};
use cosmic::iced::keyboard::Key;
use cosmic::iced::keyboard::key::Named;
use std::collections::HashMap;
use crate::Action;

View file

@ -1,50 +1,64 @@
// SPDX-License-Identifier: GPL-3.0-only
use std::str::FromStr;
use std::sync::OnceLock;
use i18n_embed::{
DefaultLocalizer, LanguageLoader, Localizer,
fluent::{FluentLanguageLoader, fluent_language_loader},
};
use icu_collator::{Collator, CollatorOptions, Numeric};
use icu_provider::DataLocale;
use i18n_embed::fluent::{FluentLanguageLoader, fluent_language_loader};
use i18n_embed::{DefaultLocalizer, LanguageLoader, Localizer};
use icu_collator::options::CollatorOptions;
use icu_collator::preferences::CollationNumericOrdering;
use icu_collator::{Collator, CollatorBorrowed, CollatorPreferences};
use icu_locale::Locale;
use rust_embed::RustEmbed;
use std::sync::LazyLock;
#[derive(RustEmbed)]
#[folder = "i18n/"]
struct Localizations;
pub static LANGUAGE_LOADER: OnceLock<FluentLanguageLoader> = OnceLock::new();
pub static LANGUAGE_SORTER: OnceLock<Collator> = OnceLock::new();
pub static LANGUAGE_LOADER: LazyLock<FluentLanguageLoader> = LazyLock::new(|| {
let loader: FluentLanguageLoader = fluent_language_loader!();
loader
.load_fallback_language(&Localizations)
.expect("Error while loading fallback language");
loader
});
pub static LANGUAGE_SORTER: LazyLock<CollatorBorrowed> = LazyLock::new(|| {
let create_collator = |locale: Locale| {
let mut prefs = CollatorPreferences::from(locale);
prefs.numeric_ordering = Some(CollationNumericOrdering::True);
Collator::try_new(prefs, CollatorOptions::default()).ok()
};
Locale::try_from_str(&LANGUAGE_LOADER.current_language().to_string())
.ok()
.and_then(create_collator)
.or_else(|| {
Locale::try_from_str(&LANGUAGE_LOADER.fallback_language().to_string())
.ok()
.and_then(create_collator)
})
.unwrap_or_else(|| {
let locale = Locale::try_from_str("en-US").expect("en-US is a valid BCP-47 tag");
create_collator(locale)
.expect("Creating a collator from the system's current language, the fallback language, or American English should succeed")
})
});
#[macro_export]
macro_rules! fl {
($message_id:literal) => {{
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER.get().unwrap(), $message_id)
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id)
}};
($message_id:literal, $($args:expr),*) => {{
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER.get().unwrap(), $message_id, $($args), *)
i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id, $($args), *)
}};
}
// Get the `Localizer` to be used for localizing this library.
pub fn localizer() -> Box<dyn Localizer> {
LANGUAGE_LOADER.get_or_init(|| {
let loader: FluentLanguageLoader = fluent_language_loader!();
loader
.load_fallback_language(&Localizations)
.expect("Error while loading fallback language");
loader
});
Box::from(DefaultLocalizer::new(
LANGUAGE_LOADER.get().unwrap(),
&Localizations,
))
Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations))
}
pub fn localize() {
@ -52,25 +66,6 @@ pub fn localize() {
let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages();
if let Err(error) = localizer.select(&requested_languages) {
eprintln!("Error while loading language for App List {}", error);
eprintln!("Error while loading language for COSMIC Media Player {error}",);
}
}
pub fn sorter() -> &'static Collator {
LANGUAGE_SORTER.get_or_init(|| {
let mut options = CollatorOptions::new();
options.numeric = Some(Numeric::On);
let localizer = localizer();
let language_loader = localizer.language_loader();
DataLocale::from_str(&language_loader.current_language().to_string())
.or_else(|_| DataLocale::from_str(&language_loader.fallback_language().to_string()))
.ok()
.and_then(|locale| Collator::try_new(&locale, options).ok())
.or_else(|| {
let locale = DataLocale::from_str("en-US").expect("en-US is a valid BCP-47 tag");
Collator::try_new(&locale, options).ok()
})
.expect("Creating a collator from the system's current language, the fallback language, or American English should succeed")
})
}

View file

@ -1,49 +1,32 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::{
action,
app::{Core, Settings, Task},
command::set_theme,
cosmic_config::{self, CosmicConfigEntry},
cosmic_theme, executor, font,
iced::{
event::{self, Event},
keyboard::{Event as KeyEvent, Key, Modifiers},
mouse::{Event as MouseEvent, ScrollDelta},
window::{self, set_mode},
Alignment, Background, Border, Color, ContentFit, Length, Limits, Subscription,
},
theme,
widget::{self, menu::action::MenuAction, nav_bar, segmented_button, Slider},
Application, ApplicationExt, Element,
};
use iced_video_player::{
gst::{self, prelude::*},
gst_pbutils, Video, VideoPlayer,
};
use std::{
any::TypeId,
collections::HashMap,
ffi::{CStr, CString},
fs,
path::{Path, PathBuf},
process,
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
thread,
time::{Duration, Instant},
use cosmic::app::{Core, Settings, Task};
use cosmic::command::set_theme;
use cosmic::cosmic_config::{self, CosmicConfigEntry};
use cosmic::iced::event::{self, Event};
use cosmic::iced::keyboard::{Event as KeyEvent, Key, Modifiers};
use cosmic::iced::mouse::{Event as MouseEvent, ScrollDelta};
use cosmic::iced::{
Alignment, Background, Border, Color, ContentFit, Length, Limits, Subscription, exit, window,
};
use cosmic::iced::window::set_mode;
use cosmic::widget::menu::action::MenuAction;
use cosmic::widget::{self, Slider, nav_bar, segmented_button};
use cosmic::{Application, ApplicationExt, Element, action, cosmic_theme, executor, font, theme};
use iced_video_player::gst::prelude::*;
use iced_video_player::{Video, VideoPlayer, gst, gst_pbutils};
use std::any::TypeId;
use std::collections::HashMap;
use std::ffi::{CStr, CString};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use std::{fs, process, thread};
use tokio::sync::mpsc;
use crate::{
config::{Config, ConfigState, CONFIG_VERSION, RepeatState},
key_bind::{key_binds, KeyBind},
mpris::subscription,
project::ProjectNode,
};
use crate::config::{CONFIG_VERSION, Config, ConfigState, RepeatState};
use crate::key_bind::{KeyBind, key_binds};
use crate::project::ProjectNode;
mod argparse;
mod config;
@ -64,6 +47,12 @@ const GST_PLAY_FLAG_VIDEO: i32 = 1 << 0;
const GST_PLAY_FLAG_AUDIO: i32 = 1 << 1;
const GST_PLAY_FLAG_TEXT: i32 = 1 << 2;
const JUMP_BACKWARD_ICON: &[u8] =
include_bytes!("../res/icons/hicolor/16x16/apps/jump-backward-10-symbolic.svg");
const JUMP_FORWARD_ICON: &[u8] =
include_bytes!("../res/icons/hicolor/16x16/apps/jump-forward-10-symbolic.svg");
use std::error::Error;
fn language_name(code: &str) -> Option<String> {
@ -187,6 +176,8 @@ pub enum Action {
FolderOpenRecent(usize),
Fullscreen,
PlayPause,
PlayPrev,
PlayNext,
SeekBackward,
SeekForward,
NextFrame,
@ -210,6 +201,8 @@ impl MenuAction for Action {
Self::FolderOpenRecent(index) => Message::FolderOpenRecent(*index),
Self::Fullscreen => Message::Fullscreen,
Self::PlayPause => Message::PlayPause,
Self::PlayPrev => Message::PlayPrev,
Self::PlayNext => Message::PlayNext,
Self::SeekBackward => Message::SeekRelative(-10.0),
Self::SeekForward => Message::SeekRelative(10.0),
Self::NextFrame => Message::NextFrame,
@ -308,6 +301,7 @@ pub enum Message {
Seek(f64),
SeekRelative(f64),
SeekRelease,
PlayPrev,
PlayNext,
NextFrame,
PreviousFrame,
@ -611,10 +605,10 @@ impl App {
}
fn save_config_state(&mut self) {
if let Some(ref config_state_handler) = self.flags.config_state_handler {
if let Err(err) = self.flags.config_state.write_entry(config_state_handler) {
log::error!("failed to save config_state: {}", err);
}
if let Some(ref config_state_handler) = self.flags.config_state_handler
&& let Err(err) = self.flags.config_state.write_entry(config_state_handler)
{
log::error!("failed to save config_state: {}", err);
}
}
@ -623,9 +617,9 @@ impl App {
|| !self
.video_opt
.as_ref()
.map_or(false, |video| video.has_video())
.is_some_and(|video| video.has_video())
{
self.core.window.show_headerbar = true && !self.fullscreen;
self.core.window.show_headerbar = !self.fullscreen;
self.controls = true;
self.controls_time = Instant::now();
} else if self.controls && self.controls_time.elapsed() > CONTROLS_TIMEOUT {
@ -679,7 +673,7 @@ impl App {
..Default::default()
};
//TODO: use any other stream tags?
if let Some(tags) = self.audio_tags.get(0) {
if let Some(tags) = self.audio_tags.first() {
log::info!("{:#?}", tags);
if let Some(tag) = tags.get::<gst::tags::Album>() {
new.album = tag.get().into();
@ -752,11 +746,11 @@ impl App {
new.album_art_opt = url::Url::from_file_path(album_art.path()).ok();
}
}
if let Some((old, _, tx)) = &mut self.mpris_opt {
if new != *old {
*old = new.clone();
let _ = tx.send(MprisEvent::Meta(new.clone()));
}
if let Some((old, _, tx)) = &mut self.mpris_opt
&& new != *old
{
*old = new.clone();
let _ = tx.send(MprisEvent::Meta(new.clone()));
}
self.mpris_meta = new;
}
@ -951,12 +945,17 @@ impl Application for App {
fn on_escape(&mut self) -> Task<Self::Message> {
if self.fullscreen {
return self.update(Message::Fullscreen);
self.update(Message::Fullscreen)
} else {
Task::none()
}
}
fn on_app_exit(&mut self) -> Option<Self::Message> {
self.close();
None
}
fn on_nav_select(&mut self, id: nav_bar::Id) -> Task<Message> {
// Toggle open state and get clone of node data
let node_opt = match self.nav_model.data_mut::<ProjectNode>(id) {
@ -1210,12 +1209,12 @@ impl Application for App {
}
}
Message::AudioCode(code) => {
if let Ok(code) = i32::try_from(code) {
if let Some(video) = &self.video_opt {
let pipeline = video.pipeline();
pipeline.set_property("current-audio", code);
self.current_audio = pipeline.property("current-audio");
}
if let Ok(code) = i32::try_from(code)
&& let Some(video) = &self.video_opt
{
let pipeline = video.pipeline();
pipeline.set_property("current-audio", code);
self.current_audio = pipeline.property("current-audio");
}
}
Message::AudioToggle => {
@ -1225,12 +1224,12 @@ impl Application for App {
}
}
Message::AudioVolume(volume) => {
if let Some(video) = &mut self.video_opt {
if volume >= 0.0 && volume <= 1.0 {
video.set_volume(volume);
video.set_muted(false);
self.update_controls(true);
}
if let Some(video) = &mut self.video_opt
&& (0.0..=1.0).contains(&volume)
{
video.set_volume(volume);
video.set_muted(false);
self.update_controls(true);
}
}
Message::TextCode(index) => {
@ -1313,7 +1312,7 @@ impl Application for App {
}
}
if (volume >= 0.0 && volume <= 1.0) && !nav_bar_toggled {
if (0.0..=1.0).contains(&volume) && !nav_bar_toggled {
video.set_volume(volume);
video.set_muted(false);
self.update_controls(true);
@ -1355,6 +1354,55 @@ impl Application for App {
}
}
Message::PlayPrev => {
if self.flags.config_state.player_state.repeat == RepeatState::Track {
return Task::none();
}
//first we get info about current media id & position in nav_bar
let curr_id = self.nav_model.active();
let curr_position = match self.nav_model.position(curr_id) {
Some(pos) => pos,
None => {
log::warn!("Failed to get position of current media: {:?}", curr_id);
return self.update(Message::EndOfStream);
}
};
if let Some(video) = &mut self.video_opt {
self.position = video.position().as_secs_f64();
if self.position > 3.0 {
video.seek(0, false).expect("seek");
} else {
if self.nav_model.activate_position(curr_position - 1) {
let curr_id = self.nav_model.active();
match self.nav_model.data::<ProjectNode>(curr_id) {
//The prev one is a media file, we play it.
Some(ProjectNode::File { .. }) => {
return self.on_nav_select(curr_id);
}
//The prev one is a folder. We expand it and recall PlayPrev.
Some(ProjectNode::Folder { .. }) => {
let _ = self.on_nav_select(curr_id);
return self.update(Message::PlayPrev);
}
//Unknown type. We do nothing.
_ => log::warn!(
"unknown type: {:?}",
self.nav_model.data::<ProjectNode>(curr_id)
),
}
} else {
// first file
video.seek(0, false).expect("seek");
}
}
}
}
Message::PlayNext => {
// TODO: known limitations:
// 1) if the user collapses the folder entry while a song is playing,
@ -1537,20 +1585,20 @@ impl Application for App {
self.update_mpris_state();
}
Message::NewFrame => {
if let Some(video) = &mut self.video_opt {
if !self.dragging {
self.position = video.position().as_secs_f64();
if let Some(video) = &mut self.video_opt
&& !self.dragging
{
self.position = video.position().as_secs_f64();
if let Some((a, b)) = self.ab_repeat {
let target_a = a.unwrap_or(0.0);
let target_b = b.unwrap_or(self.duration);
if self.position >= target_b {
let _ = video.seek(Duration::from_secs_f64(target_a), true);
}
if let Some((a, b)) = self.ab_repeat {
let target_a = a.unwrap_or(0.0);
let target_b = b.unwrap_or(self.duration);
if self.position >= target_b {
let _ = video.seek(Duration::from_secs_f64(target_a), true);
}
self.update_controls(self.dropdown_opt.is_some());
}
self.update_controls(self.dropdown_opt.is_some());
}
}
Message::Reload => {
@ -1563,7 +1611,8 @@ impl Application for App {
return self.update_config();
}
Message::WindowClose => {
process::exit(0);
self.close();
return exit();
}
}
Task::none()
@ -1796,19 +1845,55 @@ impl Application for App {
);
}
if self.controls {
let mut row = widget::row::with_capacity(7)
let mut row = widget::row::with_capacity(9)
.align_y(Alignment::Center)
.spacing(space_xxs)
.push(
widget::button::icon(
if self.video_opt.as_ref().map_or(true, |video| video.paused()) {
widget::icon::from_name("media-playback-start-symbolic").size(16)
} else {
widget::icon::from_name("media-playback-pause-symbolic").size(16)
},
)
.on_press(Message::PlayPause),
if self
.video_opt
.as_ref()
.map_or(true, |video| video.has_video())
{
widget::button::icon(
widget::icon::from_svg_bytes(JUMP_BACKWARD_ICON).symbolic(true),
)
.on_press(Message::SeekRelative(-10.0))
} else {
widget::button::icon(
widget::icon::from_name("media-skip-backward-symbolic").size(16),
)
.on_press(Message::PlayPrev)
},
);
row = row.push(
widget::button::icon(
if self.video_opt.as_ref().map_or(true, |video| video.paused()) {
widget::icon::from_name("media-playback-start-symbolic").size(16)
} else {
widget::icon::from_name("media-playback-pause-symbolic").size(16)
},
)
.on_press(Message::PlayPause),
);
row = row.push(
if self
.video_opt
.as_ref()
.map_or(true, |video| video.has_video())
{
widget::button::icon(
widget::icon::from_svg_bytes(JUMP_FORWARD_ICON).symbolic(true),
)
.on_press(Message::SeekRelative(10.0))
} else {
widget::button::icon(
widget::icon::from_name("media-skip-forward-symbolic").size(16),
)
.on_press(Message::PlayNext)
},
);
row = row.push(widget::tooltip(
widget::button::icon(
widget::icon::from_name(match self.flags.config_state.player_state.repeat {

View file

@ -1,14 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::{
theme,
widget::{
menu::{self, key_bind::KeyBind, ItemHeight, ItemWidth, MenuBar},
RcElementWrapper,
},
Element,
};
use std::{collections::HashMap, path::PathBuf};
use cosmic::widget::RcElementWrapper;
use cosmic::widget::menu::key_bind::KeyBind;
use cosmic::widget::menu::{self, ItemHeight, ItemWidth, MenuBar};
use cosmic::{Element, theme};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::{Action, Config, ConfigState, Message, fl};
@ -22,10 +19,10 @@ pub fn menu_bar<'a>(
) -> Element<'a, Message> {
let home_dir_opt = std::env::home_dir();
let format_path = |path: &PathBuf| -> String {
if let Some(home_dir) = &home_dir_opt {
if let Ok(part) = path.strip_prefix(home_dir) {
return format!("~/{}", part.display());
}
if let Some(home_dir) = &home_dir_opt
&& let Ok(part) = path.strip_prefix(home_dir)
{
return format!("~/{}", part.display());
}
path.display().to_string()
};

View file

@ -1,18 +1,16 @@
use cosmic::{
iced::{
futures::{self, SinkExt, Stream},
Subscription, stream,
},
};
use cosmic::iced::futures::{self, SinkExt, Stream};
use cosmic::iced::{Subscription, stream};
use mpris_server::zbus::{Result, fdo};
use mpris_server::{
LoopStatus, Metadata, PlaybackRate, PlaybackStatus, PlayerInterface, Property, RootInterface,
Server, Signal, Time, TrackId, Volume,
zbus::{Result, fdo},
};
use std::{any::TypeId, future, process};
use std::any::TypeId;
use std::{future, process};
use tokio::sync::{Mutex, mpsc};
use crate::{Message, MprisEvent, MprisMeta, MprisState, config::RepeatState};
use crate::config::RepeatState;
use crate::{Message, MprisEvent, MprisMeta, MprisState};
impl MprisMeta {
fn metadata(&self) -> Metadata {
@ -152,12 +150,12 @@ impl RootInterface for Player {
impl PlayerInterface for Player {
async fn next(&self) -> fdo::Result<()> {
log::info!("Next");
Ok(())
self.message(Message::PlayNext).await
}
async fn previous(&self) -> fdo::Result<()> {
log::info!("Previous");
Ok(())
self.message(Message::PlayPrev).await
}
async fn pause(&self) -> fdo::Result<()> {
@ -273,12 +271,12 @@ impl PlayerInterface for Player {
async fn can_go_next(&self) -> fdo::Result<bool> {
log::info!("CanGoNext");
Ok(false)
Ok(true)
}
async fn can_go_previous(&self) -> fdo::Result<bool> {
log::info!("CanGoPrevious");
Ok(false)
Ok(true)
}
async fn can_play(&self) -> fdo::Result<bool> {

View file

@ -1,11 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::widget;
use std::{
cmp::Ordering,
fs, io,
path::{Path, PathBuf},
};
use std::cmp::Ordering;
use std::path::{Path, PathBuf};
use std::{fs, io};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ProjectNode {
@ -26,15 +24,15 @@ impl ProjectNode {
let path = fs::canonicalize(path)?;
let name = path
.file_name()
.ok_or(io::Error::new(
io::ErrorKind::Other,
format!("path {:?} has no file name", path),
))?
.ok_or(io::Error::other(format!(
"path {:?} has no file name",
path
)))?
.to_str()
.ok_or(io::Error::new(
io::ErrorKind::Other,
format!("path {:?} is not valid UTF-8", path),
))?
.ok_or(io::Error::other(format!(
"path {:?} is not valid UTF-8",
path
)))?
.to_string();
Ok(if path.is_dir() {
Self::Folder {
@ -94,7 +92,7 @@ impl Ord for ProjectNode {
}
}
}
crate::localize::sorter().compare(self.name(), other.name())
crate::localize::LANGUAGE_SORTER.compare(self.name(), other.name())
}
}

View file

@ -1,6 +1,9 @@
use iced_video_player::Position;
use image::{DynamicImage, ImageFormat, RgbaImage};
use std::{error::Error, num::NonZero, path::Path, time::Duration};
use std::error::Error;
use std::num::NonZero;
use std::path::Path;
use std::time::Duration;
use url::Url;
use super::video;
@ -14,7 +17,7 @@ pub fn main(
let thumbnails = {
let mut video = match video::new_video(input, video::VideoSettings { mute: true }) {
Ok(ok) => ok,
Err(_err) => return Err(Into::into(format!("missing required plugin"))),
Err(_err) => return Err(Into::into("missing required plugin".to_string())),
};
let duration = video.duration();
@ -37,7 +40,7 @@ pub fn main(
pixels,
} => RgbaImage::from_raw(*width, *height, pixels.to_vec())
.map(DynamicImage::ImageRgba8)
.ok_or_else(|| format!("failed to convert thumbnail")),
.ok_or_else(|| "failed to convert thumbnail".to_string()),
_ => Err(format!("unsupported thumbnail handle {:?}", thumbnails[0])),
}
}?;

View file

@ -1,10 +1,8 @@
use iced_video_player::{
Video,
gst::{self, prelude::*},
gst_app, gst_pbutils,
};
use iced_video_player::gst::prelude::*;
use iced_video_player::{Video, gst, gst_app, gst_pbutils};
use cosmic::{action, app::Task};
use cosmic::action;
use cosmic::app::Task;
#[derive(Debug, Default)]
pub struct VideoSettings {
@ -33,13 +31,13 @@ pub fn new_video(
let Ok(elem) = vals[1].get::<gst::Element>() else {
return None;
};
if let Some(factory) = elem.factory() {
if factory.name() == "souphttpsrc" {
elem.set_property(
"user-agent",
"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0",
);
}
if let Some(factory) = elem.factory()
&& factory.name() == "souphttpsrc"
{
elem.set_property(
"user-agent",
"Mozilla/5.0 (X11; Linux x86_64; rv:142.0) Gecko/20100101 Firefox/142.0",
);
}
None
});
@ -65,18 +63,15 @@ pub fn new_video(
.unwrap()
.pop_filtered(&[gst::MessageType::Element])
{
match msg.view() {
gst::MessageView::Element(element) => {
if gst_pbutils::MissingPluginMessage::is(&element) {
commands.push(Task::perform(
async { action::app(super::Message::MissingPlugin(msg)) },
|x| x,
));
// Do one codec install at a time
break;
}
}
_ => {}
if let gst::MessageView::Element(element) = msg.view()
&& gst_pbutils::MissingPluginMessage::is(element)
{
commands.push(Task::perform(
async { action::app(super::Message::MissingPlugin(msg)) },
|x| x,
));
// Do one codec install at a time
break;
}
}
pipeline.set_state(gst::State::Null).unwrap();

View file

@ -3,10 +3,8 @@
//! Integrations with XDG portals.
use ashpd::{
desktop::inhibit::{InhibitFlags, InhibitProxy},
enumflags2::{BitFlags, make_bitflags},
};
use ashpd::desktop::inhibit::{InhibitFlags, InhibitProxy};
use ashpd::enumflags2::{BitFlags, make_bitflags};
use log::{debug, warn};
use tokio::sync::watch::Receiver;

2
vendor/iced_video_player/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
.direnv

88
vendor/iced_video_player/Cargo.toml vendored Normal file
View file

@ -0,0 +1,88 @@
[package]
name = "iced_video_player"
description = "A convenient video player widget for Iced"
homepage = "https://github.com/jazzfool/iced_video_player"
repository = "https://github.com/jazzfool/iced_video_player"
readme = "README.md"
keywords = ["gui", "iced", "video"]
categories = ["gui", "multimedia"]
version = "0.6.0"
authors = ["jazzfool"]
edition = "2021"
resolver = "2"
license = "MIT OR Apache-2.0"
exclude = [".media/test.mp4"]
[dependencies]
gstreamer = "0.25"
gstreamer-app = "0.25" # appsink
gstreamer-base = "0.25" # basesrc
gstreamer-pbutils = "0.25"
glib = "0.22" # gobject traits and error type
log = "0.4"
thiserror = "2"
url = "2" # media uri
html-escape = "0.2.13"
iced_wgpu = { path = "../../../libcosmic/iced/wgpu", default-features = false, optional = true }
[dependencies.cosmic]
package = "libcosmic-yoda"
path = "../../../libcosmic"
default-features = false
features = ["tokio", "winit"]
[dev-dependencies]
env_logger = "0.11"
[features]
default = ["wgpu"]
wgpu = ["cosmic/wgpu", "iced_wgpu"]
[package.metadata.nix]
systems = ["x86_64-linux"]
app = true
build = true
runtimeLibs = [
"vulkan-loader",
"wayland",
"wayland-protocols",
"libxkbcommon",
"xorg.libX11",
"xorg.libXrandr",
"xorg.libXi",
"gst_all_1.gstreamer",
"gst_all_1.gstreamermm",
"gst_all_1.gst-plugins-bad",
"gst_all_1.gst-plugins-ugly",
"gst_all_1.gst-plugins-good",
"gst_all_1.gst-plugins-base",
]
buildInputs = [
"libxkbcommon",
"gst_all_1.gstreamer",
"gst_all_1.gstreamermm",
"gst_all_1.gst-plugins-bad",
"gst_all_1.gst-plugins-ugly",
"gst_all_1.gst-plugins-good",
"gst_all_1.gst-plugins-base",
]
[package.metadata.docs.rs]
rustc-args = ["--cfg", "docsrs"]
rustdoc-args = ["--cfg", "docsrs"]
targets = ["wasm32-unknown-unknown"]
# [patch.'https://github.com/pop-os/libcosmic']
# libcosmic = { path = "../libcosmic" }
# cosmic-config = { path = "../libcosmic/cosmic-config" }
# cosmic-theme = { path = "../libcosmic/cosmic-theme" }
# libcosmic = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" }
# cosmic-config = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" }
# cosmic-theme = { git = "https://github.com/pop-os/libcosmic//", branch = "iced-rebase" }
[dev_dependencies]
libcosmic = { git = "https://github.com/pop-os/libcosmic", features = [
"wgpu",
"wayland",
] }

176
vendor/iced_video_player/LICENSE-APACHE vendored Normal file
View file

@ -0,0 +1,176 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

23
vendor/iced_video_player/LICENSE-MIT vendored Normal file
View file

@ -0,0 +1,23 @@
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

64
vendor/iced_video_player/README.md vendored Normal file
View file

@ -0,0 +1,64 @@
# Iced Video Player Widget
Composable component to play videos in any Iced application built on the excellent GStreamer library.
<img src=".media/screenshot.png" width="50%" />
## Overview
In general, this supports anything that [`gstreamer/playbin`](https://gstreamer.freedesktop.org/documentation/playback/playbin.html?gi-language=c) supports.
Features:
- Load video files from any file path **or URL** (support for streaming over network).
- Video buffering when streaming on a network.
- Audio support.
- Programmatic control.
- Can capture thumbnails from a set of timestamps.
- Good performance (i.e., comparable to other video players). GStreamer (with the right plugins) will perform hardware-accelerated decoding, and the color space (YUV to RGB) is converted on the GPU whilst rendering the frame.
Limitations (hopefully to be fixed):
- GStreamer is a bit annoying to set up on Windows.
The player **does not** come with any surrounding GUI controls, but they should be quite easy to implement should you need them.
See the "minimal" example for a demonstration on how you could implement pausing, looping, and seeking.
## Example Usage
```rust
use iced_video_player::{Video, VideoPlayer};
fn main() -> iced::Result {
iced::run("Video Player", (), App::view)
}
struct App {
video: Video,
}
impl Default for App {
fn default() -> Self {
App {
video: Video::new(&url::Url::parse("file:///C:/my_video.mp4").unwrap()).unwrap(),
}
}
}
impl App {
fn view(&self) -> iced::Element<()> {
VideoPlayer::new(&self.video).into()
}
}
```
## Building
Follow the [GStreamer build instructions](https://github.com/sdroege/gstreamer-rs#installation). This should be able to compile on MSVC, MinGW, Linux, and MacOS.
## License
Licensed under either
- [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0)
- [MIT](http://opensource.org/licenses/MIT)
at your option.

View file

@ -0,0 +1,154 @@
use std::time::Duration;
use cosmic::iced::{self, Program, Task, Theme};
use iced::{
application::Application,
widget::{Button, Column, Container, Row, Slider, Text},
};
use iced_video_player::{Video, VideoPlayer};
pub fn main() -> iced::Result {
application().run()
}
fn application() -> Application<impl Program<Message = Message, Theme = Theme>> {
iced::application(App::default, App::update, App::view).window_size((500.0, 800.0))
}
#[derive(Clone, Debug)]
enum Message {
TogglePause,
ToggleLoop,
Seek(f64),
SeekRelease,
EndOfStream,
NewFrame,
}
struct App {
video: Video,
position: f64,
dragging: bool,
}
impl Default for App {
fn default() -> Self {
App {
video: Video::new(
&url::Url::from_file_path(
std::path::PathBuf::from(file!())
.parent()
.unwrap()
.join("../.media/test.mp4")
.canonicalize()
.unwrap(),
)
.unwrap(),
)
.unwrap(),
position: 0.0,
dragging: false,
}
}
}
impl App {
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::TogglePause => {
self.video.set_paused(!self.video.paused());
}
Message::ToggleLoop => {
self.video.set_looping(!self.video.looping());
}
Message::Seek(secs) => {
self.dragging = true;
self.video.set_paused(true);
self.position = secs;
}
Message::SeekRelease => {
self.dragging = false;
self.video
.seek(Duration::from_secs_f64(self.position), false)
.expect("seek");
self.video.set_paused(false);
}
Message::EndOfStream => {
println!("end of stream");
}
Message::NewFrame => {
if !self.dragging {
self.position = self.video.position().as_secs_f64();
}
}
}
Task::none()
}
fn view(&self) -> iced::Element<'_, Message> {
Column::new()
.push(
Container::new(
VideoPlayer::new(&self.video)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.content_fit(iced::ContentFit::Contain)
.on_end_of_stream(Message::EndOfStream)
.on_new_frame(Message::NewFrame),
)
.align_x(iced::Alignment::Center)
.align_y(iced::Alignment::Center)
.width(iced::Length::Fill)
.height(iced::Length::Fill),
)
.push(
Container::new(
Slider::new(
0.0..=self.video.duration().as_secs_f64(),
self.position,
Message::Seek,
)
.step(0.1)
.on_release(Message::SeekRelease),
)
.padding(iced::Padding::new(5.0).left(10.0).right(10.0)),
)
.push(
Row::new()
.spacing(5)
.align_y(iced::alignment::Vertical::Center)
.padding(iced::Padding::new(10.0).top(0.0))
.push(
Button::new(Text::new(if self.video.paused() {
"Play"
} else {
"Pause"
}))
.width(80.0)
.on_press(Message::TogglePause),
)
.push(
Button::new(Text::new(if self.video.looping() {
"Disable Loop"
} else {
"Enable Loop"
}))
.width(120.0)
.on_press(Message::ToggleLoop),
)
.push(
Text::new(format!(
"{}:{:02}s / {}:{:02}s",
self.position as u64 / 60,
self.position as u64 % 60,
self.video.duration().as_secs() / 60,
self.video.duration().as_secs() % 60,
))
.width(iced::Length::Fill)
.align_x(iced::alignment::Horizontal::Right),
),
)
.into()
}
}

79
vendor/iced_video_player/src/lib.rs vendored Normal file
View file

@ -0,0 +1,79 @@
//! # Iced Video Player
//!
//! A convenient video player widget for Iced.
//!
//! To get started, load a video from a URI (e.g., a file path prefixed with `file:///`) using [`Video::new`](crate::Video::new),
//! then use it like any other Iced widget in your `view` function by creating a [`VideoPlayer`].
//!
//! Example:
//! ```rust
//! use iced_video_player::{Video, VideoPlayer};
//!
//! fn main() -> iced::Result {
//! iced::run("Video Player", (), App::view)
//! }
//!
//! struct App {
//! video: Video,
//! }
//!
//! impl Default for App {
//! fn default() -> Self {
//! App {
//! video: Video::new(&url::Url::parse("file:///C:/my_video.mp4").unwrap()).unwrap(),
//! }
//! }
//! }
//!
//! impl App {
//! fn view(&self) -> iced::Element<()> {
//! VideoPlayer::new(&self.video).into()
//! }
//! }
//! ```
//!
//! You can programmatically control the video (e.g., seek, pause, loop, grab thumbnails) by accessing various methods on [`Video`].
#[cfg(feature = "wgpu")]
mod pipeline;
mod video;
mod video_player;
pub use gstreamer as gst;
pub use gstreamer_app as gst_app;
pub use gstreamer_pbutils as gst_pbutils;
use thiserror::Error;
pub use video::Position;
pub use video::Video;
pub use video_player::VideoPlayer;
#[derive(Debug, Error)]
pub enum Error {
#[error("{0}")]
Glib(#[from] glib::Error),
#[error("{0}")]
Bool(#[from] glib::BoolError),
#[error("failed to get the gstreamer bus")]
Bus,
#[error("failed to get AppSink element with name='{0}' from gstreamer pipeline")]
AppSink(String),
#[error("{0}")]
StateChange(#[from] gst::StateChangeError),
#[error("failed to cast gstreamer element")]
Cast,
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("invalid URI")]
Uri,
#[error("failed to get media capabilities")]
Caps,
#[error("failed to query media duration or position")]
Duration,
#[error("failed to sync with playback")]
Sync,
#[error("failed to lock internal sync primitive")]
Lock,
#[error("invalid framerate: {0}")]
Framerate(f64),
}

447
vendor/iced_video_player/src/pipeline.rs vendored Normal file
View file

@ -0,0 +1,447 @@
use cosmic::iced::wgpu;
use cosmic::iced::widget::shader::{Pipeline, Primitive, Viewport};
use cosmic::iced::{self, Rectangle};
use std::{
collections::{btree_map::Entry, BTreeMap},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
};
use crate::video::Frame;
#[repr(C)]
struct Uniforms {
rect: [f32; 4],
}
struct VideoEntry {
texture_y: wgpu::Texture,
texture_uv: wgpu::Texture,
uniforms: wgpu::Buffer,
bg0: wgpu::BindGroup,
alive: Arc<AtomicBool>,
}
pub struct VideoPipeline {
pipeline: wgpu::RenderPipeline,
bg0_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
videos: BTreeMap<u64, VideoEntry>,
}
impl VideoPipeline {
fn new(device: &wgpu::Device, format: wgpu::TextureFormat, queue: &wgpu::Queue) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("iced_video_player shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
});
let bg0_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("iced_video_player bind group 0 layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("iced_video_player pipeline layout"),
bind_group_layouts: &[&bg0_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("iced_video_player pipeline"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("iced_video_player sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
lod_min_clamp: 0.0,
lod_max_clamp: 1.0,
compare: None,
anisotropy_clamp: 1,
border_color: None,
});
VideoPipeline {
pipeline,
bg0_layout,
sampler,
videos: BTreeMap::new(),
}
}
fn upload(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
video_id: u64,
alive: &Arc<AtomicBool>,
(width, height): (u32, u32),
frame: &[u8],
) {
if width == 0 || height == 0 {
return;
}
if let Entry::Vacant(entry) = self.videos.entry(video_id) {
let texture_y = device.create_texture(&wgpu::TextureDescriptor {
label: Some("iced_video_player texture"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let texture_uv = device.create_texture(&wgpu::TextureDescriptor {
label: Some("iced_video_player texture"),
size: wgpu::Extent3d {
width: width / 2,
height: height / 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rg8Unorm,
usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view_y = texture_y.create_view(&wgpu::TextureViewDescriptor {
label: Some("iced_video_player texture view"),
format: None,
dimension: None,
aspect: wgpu::TextureAspect::All,
base_mip_level: 0,
mip_level_count: None,
base_array_layer: 0,
array_layer_count: None,
usage: None,
});
let view_uv = texture_uv.create_view(&wgpu::TextureViewDescriptor {
label: Some("iced_video_player texture view"),
format: None,
dimension: None,
aspect: wgpu::TextureAspect::All,
base_mip_level: 0,
mip_level_count: None,
base_array_layer: 0,
array_layer_count: None,
usage: None,
});
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("iced_video_player uniform buffer"),
size: std::mem::size_of::<Uniforms>() as _,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::UNIFORM,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("iced_video_player bind group"),
layout: &self.bg0_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view_y),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view_uv),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &buffer,
offset: 0,
size: None,
}),
},
],
});
entry.insert(VideoEntry {
texture_y,
texture_uv,
uniforms: buffer,
bg0: bind_group,
alive: Arc::clone(alive),
});
}
let VideoEntry {
texture_y,
texture_uv,
..
} = self.videos.get(&video_id).unwrap();
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: texture_y,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&frame[..(width * height) as usize],
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(width),
rows_per_image: Some(height),
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: texture_uv,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&frame[(width * height) as usize..],
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(width),
rows_per_image: Some(height / 2),
},
wgpu::Extent3d {
width: width / 2,
height: height / 2,
depth_or_array_layers: 1,
},
);
}
fn cleanup(&mut self) {
let ids: Vec<_> = self
.videos
.iter()
.filter_map(|(id, entry)| (!entry.alive.load(Ordering::SeqCst)).then_some(*id))
.collect();
for id in ids {
if let Some(video) = self.videos.remove(&id) {
video.texture_y.destroy();
video.texture_uv.destroy();
video.uniforms.destroy();
}
}
}
fn prepare(&mut self, queue: &wgpu::Queue, video_id: u64, bounds: &iced::Rectangle) {
if let Some(video) = self.videos.get(&video_id) {
let uniforms = Uniforms {
rect: [
bounds.x,
bounds.y,
bounds.x + bounds.width,
bounds.y + bounds.height,
],
};
queue.write_buffer(&video.uniforms, 0, unsafe {
std::slice::from_raw_parts(
&uniforms as *const _ as *const u8,
std::mem::size_of::<Uniforms>(),
)
});
}
self.cleanup();
}
fn draw(
&self,
target: &wgpu::TextureView,
encoder: &mut wgpu::CommandEncoder,
viewport: &iced::Rectangle<u32>,
video_id: u64,
) {
if let Some(video) = self.videos.get(&video_id) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("iced_video_player render pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &video.bg0, &[]);
pass.set_viewport(
viewport.x as _,
viewport.y as _,
viewport.width as _,
viewport.height as _,
0.0,
1.0,
);
pass.draw(0..4, 0..1);
}
}
}
impl Pipeline for VideoPipeline {
fn new(device: &wgpu::Device, queue: &wgpu::Queue, format: wgpu::TextureFormat) -> Self
where
Self: Sized,
{
VideoPipeline::new(device, format, queue)
}
}
#[derive(Debug, Clone)]
pub(crate) struct VideoPrimitive {
video_id: u64,
alive: Arc<AtomicBool>,
frame: Arc<Mutex<Frame>>,
size: (u32, u32),
upload_frame: bool,
}
impl VideoPrimitive {
pub fn new(
video_id: u64,
alive: Arc<AtomicBool>,
frame: Arc<Mutex<Frame>>,
size: (u32, u32),
upload_frame: bool,
) -> Self {
VideoPrimitive {
video_id,
alive,
frame,
size,
upload_frame,
}
}
}
impl Primitive for VideoPrimitive {
type Pipeline = VideoPipeline;
fn prepare(
&self,
pipeline: &mut Self::Pipeline,
device: &wgpu::Device,
queue: &wgpu::Queue,
bounds: &Rectangle,
viewport: &Viewport,
) {
if self.upload_frame {
if let Some(readable) = self.frame.lock().expect("lock frame mutex").readable() {
pipeline.upload(
device,
queue,
self.video_id,
&self.alive,
self.size,
readable.as_slice(),
);
}
}
pipeline.prepare(queue, self.video_id, &bounds);
}
fn render(
&self,
pipeline: &Self::Pipeline,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
clip_bounds: &Rectangle<u32>,
) {
pipeline.draw(target, encoder, &clip_bounds, self.video_id);
}
}

View file

@ -0,0 +1,58 @@
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
struct Uniforms {
rect: vec4<f32>,
}
@group(0) @binding(0)
var tex_y: texture_2d<f32>;
@group(0) @binding(1)
var tex_uv: texture_2d<f32>;
@group(0) @binding(2)
var s: sampler;
@group(0) @binding(3)
var<uniform> uniforms: Uniforms;
@vertex
fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> VertexOutput {
let quad = array<vec2<f32>, 6>(
uniforms.rect.xy,
uniforms.rect.zy,
uniforms.rect.xw,
uniforms.rect.zy,
uniforms.rect.zw,
uniforms.rect.xw,
);
var out: VertexOutput;
out.uv = vec2<f32>(0.0);
out.uv.x = select(0.0, 2.0, in_vertex_index == 1u);
out.uv.y = select(0.0, 2.0, in_vertex_index == 2u);
out.position = vec4<f32>(out.uv * vec2<f32>(2.0, -2.0) + vec2<f32>(-1.0, 1.0), 1.0, 1.0);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let yuv2r = vec3<f32>(1.164, 0.0, 1.596);
let yuv2g = vec3<f32>(1.164, -0.391, -0.813);
let yuv2b = vec3<f32>(1.164, 2.018, 0.0);
var yuv = vec3<f32>(0.0);
yuv.x = textureSample(tex_y, s, in.uv).r - 0.0625;
yuv.y = textureSample(tex_uv, s, in.uv).r - 0.5;
yuv.z = textureSample(tex_uv, s, in.uv).g - 0.5;
var rgb = vec3<f32>(0.0);
rgb.x = dot(yuv, yuv2r);
rgb.y = dot(yuv, yuv2g);
rgb.z = dot(yuv, yuv2b);
return vec4<f32>(rgb, 1.0);
}

664
vendor/iced_video_player/src/video.rs vendored Normal file
View file

@ -0,0 +1,664 @@
use crate::Error;
use cosmic::iced::widget::image as img;
use gstreamer as gst;
use gstreamer_app as gst_app;
use gstreamer_app::prelude::*;
use std::num::NonZeroU8;
use std::ops::{Deref, DerefMut};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
const THUMBNAIL_FRAME_TIMEOUT: Duration = Duration::from_secs(10);
/// Position in the media.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Position {
/// Position based on time.
///
/// Not the most accurate format for videos.
Time(Duration),
/// Position based on nth frame.
Frame(u64),
}
impl From<Position> for gst::GenericFormattedValue {
fn from(pos: Position) -> Self {
match pos {
Position::Time(t) => gst::ClockTime::from_nseconds(t.as_nanos() as _).into(),
Position::Frame(f) => gst::format::Default::from_u64(f).into(),
}
}
}
impl From<Duration> for Position {
fn from(t: Duration) -> Self {
Position::Time(t)
}
}
impl From<u64> for Position {
fn from(f: u64) -> Self {
Position::Frame(f)
}
}
#[derive(Debug)]
pub(crate) struct Frame(gst::Sample);
impl Frame {
pub fn new() -> Self {
Self(gst::Sample::builder().build())
}
pub fn store(&mut self, sample: gst::Sample) -> Option<()> {
if sample.buffer().is_some() {
self.0 = sample;
Some(())
} else {
None
}
}
pub fn readable(&self) -> Option<gst::BufferMap<gst::buffer::Readable>> {
self.0.buffer().map(|x| x.map_readable().ok()).flatten()
}
}
#[derive(Debug)]
pub(crate) struct Internal {
pub(crate) id: u64,
pub(crate) bus: gst::Bus,
pub(crate) source: gst::Pipeline,
pub(crate) alive: Arc<AtomicBool>,
pub(crate) worker: Option<std::thread::JoinHandle<()>>,
pub(crate) has_video: bool,
pub(crate) width: i32,
pub(crate) height: i32,
pub(crate) framerate: f64,
pub(crate) duration: Duration,
pub(crate) speed: f64,
pub(crate) sync_av: bool,
pub(crate) frame: Arc<Mutex<Frame>>,
pub(crate) upload_frame: Arc<AtomicBool>,
pub(crate) redrawing: Arc<AtomicBool>,
pub(crate) last_frame_time: Arc<Mutex<Instant>>,
pub(crate) looping: bool,
pub(crate) is_eos: bool,
pub(crate) restart_stream: bool,
pub(crate) sync_av_avg: u64,
pub(crate) sync_av_counter: u64,
pub(crate) subtitle_text: Arc<Mutex<Option<String>>>,
pub(crate) upload_text: Arc<AtomicBool>,
#[cfg(not(feature = "wgpu"))]
pub(crate) handle_opt: Option<img::Handle>,
}
impl Internal {
pub(crate) fn seek(&self, position: impl Into<Position>, accurate: bool) -> Result<(), Error> {
let position = position.into();
// gstreamer complains if the start & end value types aren't the same
match &position {
Position::Time(_) => self.source.seek(
self.speed,
gst::SeekFlags::FLUSH
| if accurate {
gst::SeekFlags::ACCURATE
} else {
gst::SeekFlags::empty()
},
gst::SeekType::Set,
gst::GenericFormattedValue::from(position),
gst::SeekType::Set,
gst::ClockTime::NONE,
)?,
Position::Frame(_) => self.source.seek(
self.speed,
gst::SeekFlags::FLUSH
| if accurate {
gst::SeekFlags::ACCURATE
} else {
gst::SeekFlags::empty()
},
gst::SeekType::Set,
gst::GenericFormattedValue::from(position),
gst::SeekType::Set,
gst::format::Default::NONE,
)?,
};
Ok(())
}
pub(crate) fn set_speed(&mut self, speed: f64) -> Result<(), Error> {
let Some(position) = self.source.query_position::<gst::ClockTime>() else {
return Err(Error::Caps);
};
if speed > 0.0 {
self.source.seek(
speed,
gst::SeekFlags::FLUSH | gst::SeekFlags::ACCURATE,
gst::SeekType::Set,
position,
gst::SeekType::End,
gst::ClockTime::from_seconds(0),
)?;
} else {
self.source.seek(
speed,
gst::SeekFlags::FLUSH | gst::SeekFlags::ACCURATE,
gst::SeekType::Set,
gst::ClockTime::from_seconds(0),
gst::SeekType::Set,
position,
)?;
}
self.speed = speed;
Ok(())
}
pub(crate) fn restart_stream(&mut self) -> Result<(), Error> {
self.is_eos = false;
self.set_paused(false);
self.seek(0, false)?;
Ok(())
}
pub(crate) fn set_paused(&mut self, paused: bool) {
self.source
.set_state(if paused {
gst::State::Paused
} else {
gst::State::Playing
})
.unwrap(/* state was changed in ctor; state errors caught there */);
// Set restart_stream flag to make the stream restart on the next Message::NextFrame
if self.is_eos && !paused {
self.restart_stream = true;
}
}
pub(crate) fn paused(&self) -> bool {
self.source.state(gst::ClockTime::ZERO).1 == gst::State::Paused
}
/// Syncs audio with video when there is (inevitably) latency presenting the frame.
pub(crate) fn set_av_offset(&mut self, offset: Duration) {
if self.sync_av {
self.sync_av_counter += 1;
self.sync_av_avg = self.sync_av_avg * (self.sync_av_counter - 1) / self.sync_av_counter
+ offset.as_nanos() as u64 / self.sync_av_counter;
if self.sync_av_counter % 128 == 0 {
self.source
.set_property("av-offset", -(self.sync_av_avg as i64));
}
}
}
}
/// A multimedia video loaded from a URI (e.g., a local file path or HTTP stream).
#[derive(Debug)]
pub struct Video(pub(crate) RwLock<Internal>);
impl Drop for Video {
fn drop(&mut self) {
let inner = self.0.get_mut().expect("failed to lock");
inner
.source
.set_state(gst::State::Null)
.expect("failed to set state");
inner.alive.store(false, Ordering::SeqCst);
if let Some(worker) = inner.worker.take() {
worker.join().expect("failed to stop video thread");
}
}
}
impl Video {
/// Create a new video player from a given video which loads from `uri`.
/// Note that live sources will report the duration to be zero.
pub fn new(uri: &url::Url) -> Result<Self, Error> {
gst::init()?;
let pipeline = format!(
"playbin uri=\"{}\" text-sink=\"appsink name=iced_text sync=true caps=text/x-raw\" video-sink=\"videoscale ! videoconvert ! appsink name=iced_video drop=true caps=video/x-raw,format=NV12,pixel-aspect-ratio=1/1\"",
uri.as_str()
);
let pipeline = gst::parse::launch(pipeline.as_ref())?
.downcast::<gst::Pipeline>()
.map_err(|_| Error::Cast)?;
let video_sink: gst::Element = pipeline.property("video-sink");
let pad = video_sink.pads().first().cloned().unwrap();
let pad = pad.dynamic_cast::<gst::GhostPad>().unwrap();
let bin = pad
.parent_element()
.unwrap()
.downcast::<gst::Bin>()
.unwrap();
let video_sink = bin.by_name("iced_video").unwrap();
let video_sink = video_sink.downcast::<gst_app::AppSink>().unwrap();
let text_sink: gst::Element = pipeline.property("text-sink");
let text_sink = text_sink.downcast::<gst_app::AppSink>().unwrap();
Self::from_gst_pipeline(pipeline, video_sink, Some(text_sink))
}
/// Creates a new video based on an existing GStreamer pipeline and appsink.
/// Expects an `appsink` plugin with `caps=video/x-raw,format=NV12`.
///
/// An optional `text_sink` can be provided, which enables subtitle messages
/// to be emitted.
///
/// **Note:** Many functions of [`Video`] assume a `playbin` pipeline.
/// Non-`playbin` pipelines given here may not have full functionality.
pub fn from_gst_pipeline(
pipeline: gst::Pipeline,
video_sink: gst_app::AppSink,
text_sink: Option<gst_app::AppSink>,
) -> Result<Self, Error> {
gst::init()?;
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
let pad = video_sink.pads().first().cloned().unwrap();
pipeline.set_state(gst::State::Playing)?;
// wait for up to 5 seconds until the decoder gets the source capabilities
pipeline.state(gst::ClockTime::from_seconds(5)).0?;
// extract resolution and framerate
// TODO(jazzfool): maybe we want to extract some other information too?
let (width, height, framerate, has_video) = if let Some(caps) = pad.current_caps() {
let s = caps.structure(0).ok_or(Error::Caps)?;
let width = s.get::<i32>("width").map_err(|_| Error::Caps)?;
let height = s.get::<i32>("height").map_err(|_| Error::Caps)?;
// resolution should be mod4
let width = ((width + 4 - 1) / 4) * 4;
let framerate = s
.get::<gst::Fraction>("framerate")
.map_err(|_| Error::Caps)?;
let framerate = framerate.numer() as f64 / framerate.denom() as f64;
(width, height, framerate, true)
} else {
log::warn!("Video caps not found, falling back to audio only");
(0, 0, 4.0, false)
};
if framerate.is_nan()
|| framerate.is_infinite()
|| framerate < 0.0
|| framerate.abs() < f64::EPSILON
{
return Err(Error::Framerate(framerate));
}
let duration = Duration::from_nanos(
pipeline
.query_duration::<gst::ClockTime>()
.map(|duration| duration.nseconds())
.unwrap_or(0),
);
let sync_av = pipeline.has_property("av-offset");
// NV12 = 12bpp
let frame = Arc::new(Mutex::new(Frame::new()));
let upload_frame = Arc::new(AtomicBool::new(false));
let alive = Arc::new(AtomicBool::new(true));
let last_frame_time = Arc::new(Mutex::new(Instant::now()));
let frame_ref = Arc::clone(&frame);
let upload_frame_ref = Arc::clone(&upload_frame);
let alive_ref = Arc::clone(&alive);
let last_frame_time_ref = Arc::clone(&last_frame_time);
let subtitle_text = Arc::new(Mutex::new(None));
let upload_text = Arc::new(AtomicBool::new(false));
let subtitle_text_ref = Arc::clone(&subtitle_text);
let upload_text_ref = Arc::clone(&upload_text);
let pipeline_ref = pipeline.clone();
let worker = std::thread::spawn(move || {
let mut clear_subtitles_at = None;
while alive_ref.load(Ordering::Acquire) {
match (|| -> Result<(), gst::FlowError> {
let sample =
if pipeline_ref.state(gst::ClockTime::ZERO).1 != gst::State::Playing {
video_sink
.try_pull_preroll(gst::ClockTime::from_mseconds(16))
.ok_or(gst::FlowError::Eos)?
} else {
video_sink
.try_pull_sample(gst::ClockTime::from_mseconds(16))
.ok_or(gst::FlowError::Eos)?
};
*last_frame_time_ref
.lock()
.map_err(|_| gst::FlowError::Error)? = Instant::now();
let buffer = sample.buffer().ok_or(gst::FlowError::Error)?;
let pts = buffer.pts().unwrap_or_default();
{
let mut frame_guard =
frame_ref.lock().map_err(|_| gst::FlowError::Error)?;
*frame_guard = Frame(sample);
}
upload_frame_ref.swap(true, Ordering::SeqCst);
if let Some(at) = clear_subtitles_at {
if pts >= at {
*subtitle_text_ref
.lock()
.map_err(|_| gst::FlowError::Error)? = None;
upload_text_ref.store(true, Ordering::SeqCst);
clear_subtitles_at = None;
}
}
let text = text_sink
.as_ref()
.and_then(|sink| sink.try_pull_sample(gst::ClockTime::from_seconds(0)));
if let Some(text) = text {
let text = text.buffer().ok_or(gst::FlowError::Error)?;
let pts = text.pts().unwrap_or_default();
let duration = text.duration().unwrap_or(gst::ClockTime::ZERO);
let map = text.map_readable().map_err(|_| gst::FlowError::Error)?;
let text = html_escape::decode_html_entities(
std::str::from_utf8(map.as_slice())
.map_err(|_| gst::FlowError::Error)?,
)
.to_string();
*subtitle_text_ref
.lock()
.map_err(|_| gst::FlowError::Error)? = Some(text);
upload_text_ref.store(true, Ordering::SeqCst);
clear_subtitles_at = Some(pts + duration);
}
Ok(())
})() {
Ok(()) => {}
Err(gst::FlowError::Eos) => {
if !has_video {
// Simulate frame upload when there is no video stream
upload_frame_ref.swap(true, Ordering::SeqCst);
std::thread::sleep(Duration::from_secs_f64(1.0 / framerate));
}
}
Err(err) => {
log::error!("error pulling frame: {err}");
}
}
}
});
Ok(Video(RwLock::new(Internal {
id,
bus: pipeline.bus().unwrap(),
source: pipeline,
alive,
worker: Some(worker),
has_video,
width,
height,
framerate,
duration,
speed: 1.0,
sync_av,
frame,
upload_frame,
redrawing: Arc::new(AtomicBool::new(false)),
last_frame_time,
looping: false,
is_eos: false,
restart_stream: false,
sync_av_avg: 0,
sync_av_counter: 0,
subtitle_text,
upload_text,
#[cfg(not(feature = "wgpu"))]
handle_opt: None,
})))
}
pub(crate) fn read(&self) -> impl Deref<Target = Internal> + '_ {
self.0.read().expect("lock")
}
pub(crate) fn write(&self) -> impl DerefMut<Target = Internal> + '_ {
self.0.write().expect("lock")
}
pub(crate) fn get_mut(&mut self) -> impl DerefMut<Target = Internal> + '_ {
self.0.get_mut().expect("lock")
}
pub fn has_video(&self) -> bool {
self.read().has_video
}
/// Get the size/resolution of the video as `(width, height)`.
pub fn size(&self) -> (i32, i32) {
(self.read().width, self.read().height)
}
/// Get the framerate of the video as frames per second.
pub fn framerate(&self) -> f64 {
self.read().framerate
}
/// Set the volume multiplier of the audio.
/// `0.0` = 0% volume, `1.0` = 100% volume.
///
/// This uses a linear scale, for example `0.5` is perceived as half as loud.
pub fn set_volume(&mut self, volume: f64) {
self.get_mut().source.set_property("volume", volume);
self.set_muted(self.muted()); // for some reason gstreamer unmutes when changing volume?
}
/// Get the volume multiplier of the audio.
pub fn volume(&self) -> f64 {
self.read().source.property("volume")
}
/// Set if the audio is muted or not, without changing the volume.
pub fn set_muted(&mut self, muted: bool) {
self.get_mut().source.set_property("mute", muted);
}
/// Get if the audio is muted or not.
pub fn muted(&self) -> bool {
self.read().source.property("mute")
}
/// Get if the stream ended or not.
pub fn eos(&self) -> bool {
self.read().is_eos
}
/// Get if the media will loop or not.
pub fn looping(&self) -> bool {
self.read().looping
}
/// Set if the media will loop or not.
pub fn set_looping(&mut self, looping: bool) {
self.get_mut().looping = looping;
}
/// Set if the media is paused or not.
pub fn set_paused(&mut self, paused: bool) {
self.get_mut().set_paused(paused)
}
/// Get if the media is paused or not.
pub fn paused(&self) -> bool {
self.read().paused()
}
/// Jumps to a specific position in the media.
/// Passing `true` to the `accurate` parameter will result in more accurate seeking,
/// however, it is also slower. For most seeks (e.g., scrubbing) this is not needed.
pub fn seek(&mut self, position: impl Into<Position>, accurate: bool) -> Result<(), Error> {
self.get_mut().seek(position, accurate)
}
/// Set the playback speed of the media.
/// The default speed is `1.0`.
pub fn set_speed(&mut self, speed: f64) -> Result<(), Error> {
self.get_mut().set_speed(speed)
}
/// Get the current playback speed.
pub fn speed(&self) -> f64 {
self.read().speed
}
/// Get the current playback position in time.
pub fn position(&self) -> Duration {
Duration::from_nanos(
self.read()
.source
.query_position::<gst::ClockTime>()
.map_or(0, |pos| pos.nseconds()),
)
}
/// Get the media duration.
pub fn duration(&self) -> Duration {
self.read().duration
}
/// Restarts a stream; seeks to the first frame and unpauses, sets the `eos` flag to false.
pub fn restart_stream(&mut self) -> Result<(), Error> {
self.get_mut().restart_stream()
}
/// Set the subtitle URL to display.
pub fn set_subtitle_url(&mut self, url: &url::Url) -> Result<(), Error> {
let paused = self.paused();
let mut inner = self.get_mut();
inner.source.set_state(gst::State::Ready)?;
inner.source.set_property("suburi", url.as_str());
inner.set_paused(paused);
Ok(())
}
/// Get the current subtitle URL.
pub fn subtitle_url(&self) -> Option<url::Url> {
url::Url::parse(&self.read().source.property::<String>("suburi")).ok()
}
/// Get the underlying GStreamer pipeline.
pub fn pipeline(&self) -> gst::Pipeline {
self.read().source.clone()
}
/// Generates a list of thumbnails based on a set of positions in the media, downscaled by a given factor.
///
/// Slow; only needs to be called once for each instance.
/// It's best to call this at the very start of playback, otherwise the position may shift.
pub fn thumbnails<I>(
&mut self,
positions: I,
downscale: NonZeroU8,
) -> Result<Vec<img::Handle>, Error>
where
I: IntoIterator<Item = Position>,
{
let downscale = u8::from(downscale) as u32;
let paused = self.paused();
let muted = self.muted();
let pos = self.position();
self.set_paused(false);
self.set_muted(true);
let out = {
let inner = self.read();
let width = inner.width;
let height = inner.height;
positions
.into_iter()
.map(|pos| {
inner.seek(pos, true)?;
inner.upload_frame.store(false, Ordering::SeqCst);
let started = Instant::now();
while !inner.upload_frame.load(Ordering::SeqCst) {
if started.elapsed() >= THUMBNAIL_FRAME_TIMEOUT {
return Err(Error::Sync);
}
std::thread::sleep(Duration::from_millis(5));
}
let frame_guard = inner.frame.lock().map_err(|_| Error::Lock)?;
let frame = frame_guard.readable().ok_or(Error::Lock)?;
Ok(img::Handle::from_rgba(
inner.width as u32 / downscale,
inner.height as u32 / downscale,
yuv_to_rgba(frame.as_slice(), width as _, height as _, downscale),
))
})
.collect()
};
self.set_paused(paused);
self.set_muted(muted);
self.seek(pos, true)?;
out
}
}
pub(crate) fn yuv_to_rgba(yuv: &[u8], width: u32, height: u32, downscale: u32) -> Vec<u8> {
let uv_start = width * height;
let mut rgba = vec![];
for y in 0..height / downscale {
for x in 0..width / downscale {
let x_src = x * downscale;
let y_src = y * downscale;
let uv_i = uv_start + width * (y_src / 2) + x_src / 2 * 2;
let y = yuv[(y_src * width + x_src) as usize] as f32;
let u = yuv[uv_i as usize] as f32;
let v = yuv[(uv_i + 1) as usize] as f32;
let r = 1.164 * (y - 16.0) + 1.596 * (v - 128.0);
let g = 1.164 * (y - 16.0) - 0.813 * (v - 128.0) - 0.391 * (u - 128.0);
let b = 1.164 * (y - 16.0) + 2.018 * (u - 128.0);
rgba.push(r as u8);
rgba.push(g as u8);
rgba.push(b as u8);
rgba.push(0xFF);
}
}
rgba
}

View file

@ -0,0 +1,476 @@
use crate::{gst, gst_pbutils, video::Video};
use cosmic::iced::{
self,
advanced::{self, layout, widget, Widget},
mouse, Element,
};
use gstreamer_app::prelude::*;
use log::error;
use std::{marker::PhantomData, sync::atomic::Ordering, time::Duration};
use std::{sync::Arc, time::Instant};
#[cfg(feature = "wgpu")]
use crate::pipeline::VideoPrimitive;
#[cfg(feature = "wgpu")]
use iced_wgpu::primitive::Renderer as PrimitiveRenderer;
#[cfg(not(feature = "wgpu"))]
use crate::video::yuv_to_rgba;
#[cfg(not(feature = "wgpu"))]
use cosmic::iced::advanced::image::Renderer as ImageRenderer;
#[cfg(not(feature = "wgpu"))]
trait PrimitiveRenderer: ImageRenderer<Handle = advanced::image::Handle> {}
#[cfg(not(feature = "wgpu"))]
impl PrimitiveRenderer for iced::Renderer {}
/// Video player widget which displays the current frame of a [`Video`](crate::Video).
pub struct VideoPlayer<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer>
where
Renderer: PrimitiveRenderer,
{
video: &'a Video,
content_fit: iced::ContentFit,
width: iced::Length,
height: iced::Length,
mouse_hidden: bool,
on_duration_changed: Option<Box<dyn Fn(Duration) -> Message + 'a>>,
on_end_of_stream: Option<Message>,
on_new_frame: Option<Message>,
on_subtitle_text: Option<Box<dyn Fn(Option<String>) -> Message + 'a>>,
on_error: Option<Box<dyn Fn(glib::Error) -> Message + 'a>>,
on_missing_plugin: Option<Box<dyn Fn(gst::Message) -> Message + 'a>>,
on_tags: Option<Box<dyn Fn(gst::TagList) -> Message + 'a>>,
on_warning: Option<Box<dyn Fn(glib::Error) -> Message + 'a>>,
id: Option<cosmic::widget::Id>,
_phantom: PhantomData<(Theme, Renderer)>,
}
impl<'a, Message, Theme, Renderer> VideoPlayer<'a, Message, Theme, Renderer>
where
Renderer: PrimitiveRenderer,
{
/// Creates a new video player widget for a given video.
pub fn new(video: &'a Video) -> Self {
VideoPlayer {
video,
content_fit: iced::ContentFit::Contain,
width: iced::Length::Shrink,
height: iced::Length::Shrink,
mouse_hidden: false,
on_duration_changed: None,
on_end_of_stream: None,
on_new_frame: None,
on_subtitle_text: None,
on_error: None,
on_missing_plugin: None,
on_tags: None,
on_warning: None,
_phantom: Default::default(),
id: None,
}
}
/// Sets the ID of the `VideoPlayer`.
pub fn id(mut self, id: cosmic::widget::Id) -> Self {
self.id = Some(id);
self
}
/// Sets the width of the `VideoPlayer` boundaries.
pub fn width(self, width: impl Into<iced::Length>) -> Self {
VideoPlayer {
width: width.into(),
..self
}
}
/// Sets the height of the `VideoPlayer` boundaries.
pub fn height(self, height: impl Into<iced::Length>) -> Self {
VideoPlayer {
height: height.into(),
..self
}
}
/// Sets the `ContentFit` of the `VideoPlayer`.
pub fn content_fit(self, content_fit: iced::ContentFit) -> Self {
VideoPlayer {
content_fit,
..self
}
}
pub fn mouse_hidden(self, mouse_hidden: bool) -> Self {
VideoPlayer {
mouse_hidden,
..self
}
}
pub fn on_duration_changed<F>(self, on_duration_changed: F) -> Self
where
F: 'a + Fn(Duration) -> Message,
{
VideoPlayer {
on_duration_changed: Some(Box::new(on_duration_changed)),
..self
}
}
/// Message to send when the video reaches the end of stream (i.e., the video ends).
pub fn on_end_of_stream(self, on_end_of_stream: Message) -> Self {
VideoPlayer {
on_end_of_stream: Some(on_end_of_stream),
..self
}
}
/// Message to send when the video receives a new frame.
pub fn on_new_frame(self, on_new_frame: Message) -> Self {
VideoPlayer {
on_new_frame: Some(on_new_frame),
..self
}
}
/// Message to send when the video receives a new frame.
pub fn on_subtitle_text<F>(self, on_subtitle_text: F) -> Self
where
F: 'a + Fn(Option<String>) -> Message,
{
VideoPlayer {
on_subtitle_text: Some(Box::new(on_subtitle_text)),
..self
}
}
/// Message to send when the video playback encounters an error.
pub fn on_error<F>(self, on_error: F) -> Self
where
F: 'a + Fn(glib::Error) -> Message,
{
VideoPlayer {
on_error: Some(Box::new(on_error)),
..self
}
}
pub fn on_missing_plugin<F>(self, on_missing_plugin: F) -> Self
where
F: 'a + Fn(gst::Message) -> Message,
{
VideoPlayer {
on_missing_plugin: Some(Box::new(on_missing_plugin)),
..self
}
}
pub fn on_tags<F>(self, on_tags: F) -> Self
where
F: 'a + Fn(gst::TagList) -> Message,
{
VideoPlayer {
on_tags: Some(Box::new(on_tags)),
..self
}
}
pub fn on_warning<F>(self, on_warning: F) -> Self
where
F: 'a + Fn(glib::Error) -> Message,
{
VideoPlayer {
on_warning: Some(Box::new(on_warning)),
..self
}
}
}
impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for VideoPlayer<'a, Message, Theme, Renderer>
where
Message: Clone,
Renderer: PrimitiveRenderer,
{
fn size(&self) -> iced::Size<iced::Length> {
iced::Size {
width: iced::Length::Shrink,
height: iced::Length::Shrink,
}
}
fn layout(
&mut self,
_tree: &mut widget::Tree,
_renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let (video_width, video_height) = self.video.size();
// based on `Image::layout`
let image_size = iced::Size::new(video_width as f32, video_height as f32);
let raw_size = limits.resolve(self.width, self.height, image_size);
let full_size = self.content_fit.fit(image_size, raw_size);
let final_size = iced::Size {
width: match self.width {
iced::Length::Shrink => f32::min(raw_size.width, full_size.width),
_ => raw_size.width,
},
height: match self.height {
iced::Length::Shrink => f32::min(raw_size.height, full_size.height),
_ => raw_size.height,
},
};
layout::Node::new(final_size)
}
fn draw(
&self,
_tree: &widget::Tree,
renderer: &mut Renderer,
_theme: &Theme,
_style: &advanced::renderer::Style,
layout: advanced::Layout<'_>,
_cursor: advanced::mouse::Cursor,
_viewport: &iced::Rectangle,
) {
let mut inner = self.video.write();
// bounds based on `Image::draw`
let image_size = iced::Size::new(inner.width as f32, inner.height as f32);
let bounds = layout.bounds();
let adjusted_fit = self.content_fit.fit(image_size, bounds.size());
let scale = iced::Vector::new(
adjusted_fit.width / image_size.width,
adjusted_fit.height / image_size.height,
);
let final_size = iced::Size::new(image_size.width * scale.x, image_size.height * scale.y);
let position = match self.content_fit {
iced::ContentFit::None => iced::Point::new(
bounds.x + (image_size.width - adjusted_fit.width) / 2.0,
bounds.y + (image_size.height - adjusted_fit.height) / 2.0,
),
_ => iced::Point::new(
bounds.center_x() - final_size.width / 2.0,
bounds.center_y() - final_size.height / 2.0,
),
};
let drawing_bounds = iced::Rectangle::new(position, final_size);
let upload_frame = inner.upload_frame.swap(false, Ordering::SeqCst);
inner.redrawing.store(false, Ordering::SeqCst);
if upload_frame {
let last_frame_time = inner
.last_frame_time
.lock()
.map(|time| *time)
.unwrap_or_else(|_| Instant::now());
inner.set_av_offset(Instant::now() - last_frame_time);
}
#[cfg(feature = "wgpu")]
renderer.draw_primitive(
drawing_bounds,
VideoPrimitive::new(
inner.id,
Arc::clone(&inner.alive),
Arc::clone(&inner.frame),
(inner.width as _, inner.height as _),
upload_frame,
),
);
#[cfg(not(feature = "wgpu"))]
{
if upload_frame {
let mut opt = None;
{
let yuv_data_opt = match inner.frame.lock() {
Ok(frame) => Some(frame),
Err(_err) => None,
};
if let Some(yuv_data) = yuv_data_opt.as_ref().and_then(|d| d.readable()) {
//TODO: convert on worker thread?
let rgba_data =
yuv_to_rgba(&yuv_data, inner.width as _, inner.height as _, 1);
opt = Some(advanced::image::Handle::from_rgba(
inner.width as _,
inner.height as _,
rgba_data,
))
};
}
inner.handle_opt = opt;
}
if let Some(handle) = &inner.handle_opt {
use cosmic::iced::Radians;
renderer.draw_image(
handle.clone(),
advanced::image::FilterMethod::Nearest,
drawing_bounds,
Radians(0.),
1.0,
[0.0; 4],
);
}
}
}
fn update(
&mut self,
_state: &mut widget::Tree,
event: &iced::Event,
_layout: advanced::Layout<'_>,
_cursor: advanced::mouse::Cursor,
_renderer: &Renderer,
_clipboard: &mut dyn advanced::Clipboard,
shell: &mut advanced::Shell<'_, Message>,
_viewport: &iced::Rectangle,
) {
let mut inner = self.video.write();
if let iced::Event::Window(iced::window::Event::RedrawRequested(_)) = event {
if inner.restart_stream || (!inner.is_eos && !inner.paused()) {
let mut restart_stream = false;
if inner.restart_stream {
restart_stream = true;
// Set flag to false to avoid potentially multiple seeks
inner.restart_stream = false;
}
let mut eos_pause = false;
while let Some(msg) = inner.bus.pop_filtered(&[
gst::MessageType::DurationChanged,
gst::MessageType::Error,
gst::MessageType::Element,
gst::MessageType::Eos,
gst::MessageType::Tag,
gst::MessageType::Warning,
]) {
match msg.view() {
gst::MessageView::DurationChanged(_) => {
inner.duration = Duration::from_nanos(
inner
.source
.query_duration::<gst::ClockTime>()
.map(|duration| duration.nseconds())
.unwrap_or(0),
);
if let Some(ref on_duration_changed) = self.on_duration_changed {
shell.publish(on_duration_changed(inner.duration));
}
}
gst::MessageView::Error(err) => {
error!("bus returned an error: {err}");
if let Some(ref on_error) = self.on_error {
shell.publish(on_error(err.error()))
};
}
gst::MessageView::Element(element) => {
if gst_pbutils::MissingPluginMessage::is(&element) {
if let Some(ref on_missing_plugin) = self.on_missing_plugin {
shell.publish(on_missing_plugin(element.copy()));
}
}
}
gst::MessageView::Eos(_eos) => {
if let Some(on_end_of_stream) = self.on_end_of_stream.clone() {
shell.publish(on_end_of_stream);
}
if inner.looping {
restart_stream = true;
} else {
eos_pause = true;
}
}
gst::MessageView::Tag(tag_msg) => {
if let Some(ref on_tags) = self.on_tags {
shell.publish(on_tags(tag_msg.tags()));
}
}
gst::MessageView::Warning(warn) => {
log::warn!("bus returned a warning: {warn}");
if let Some(ref on_warning) = self.on_warning {
shell.publish(on_warning(warn.error()));
}
}
_ => {}
}
}
// Don't run eos_pause if restart_stream is true; fixes "pausing" after restarting a stream
if restart_stream {
if let Err(err) = inner.restart_stream() {
error!("cannot restart stream (can't seek): {err:#?}");
}
} else if eos_pause {
inner.is_eos = true;
inner.set_paused(true);
}
if !inner.redrawing.load(Ordering::SeqCst)
&& inner.upload_frame.load(Ordering::SeqCst)
{
if let Some(on_new_frame) = self.on_new_frame.clone() {
inner.redrawing.store(true, Ordering::SeqCst);
shell.publish(on_new_frame);
}
}
if let Some(on_subtitle_text) = &self.on_subtitle_text {
if inner.upload_text.swap(false, Ordering::SeqCst) {
if let Ok(text) = inner.subtitle_text.try_lock() {
shell.publish(on_subtitle_text(text.clone()));
}
}
}
shell.request_redraw();
} else {
shell.request_redraw_at(iced::window::RedrawRequest::At(
Instant::now() + Duration::from_millis(32),
));
}
}
}
fn mouse_interaction(
&self,
_tree: &widget::Tree,
_layout: advanced::Layout<'_>,
_cursor_position: mouse::Cursor,
_viewport: &iced::Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
if self.mouse_hidden {
mouse::Interaction::Hidden
} else {
mouse::Interaction::default()
}
}
fn set_id(&mut self, id: widget::Id) {
self.id = Some(id);
}
fn id(&self) -> Option<widget::Id> {
self.id.clone()
}
}
impl<'a, Message, Theme, Renderer> From<VideoPlayer<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a + Clone,
Theme: 'a,
Renderer: 'a + PrimitiveRenderer,
{
fn from(video_player: VideoPlayer<'a, Message, Theme, Renderer>) -> Self {
Self::new(video_player)
}
}